Remove secrets from env files
This commit is contained in:
parent
f3a2e46116
commit
4777e3dc03
25 changed files with 3023 additions and 2342 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -32,6 +32,10 @@ __pycache__/
|
|||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Environment files
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
.turbo
|
||||
dist
|
||||
.wrangler
|
||||
175
SECURITY_NOTICE.md
Normal file
175
SECURITY_NOTICE.md
Normal 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
|
||||
|
||||
|
|
@ -1,25 +1,13 @@
|
|||
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_SECRET=zrr32sqenw3atpv9rnz2nhhyyncf7bunr7fmfqy9r7e69fcw978dhzevmhpxa2jj
|
||||
|
||||
STRIPE_SECRET_KEY=sk_test_51Qc159AmcXPHW4mTeEs86NXY2lAz6pPKiSteECBTsQ2BmaJxeFkbO4uopoMZM8USggRYJjuwJ4GCXVzy6ROT1hMJ00NJGOUM33
|
||||
STRIPE_WEBHOOK_SECRET=whsec_4c6f3742c4f3760eff1ef974202cb7f27acc93b8a0da6529db7b2ff2d5acec02
|
||||
|
||||
XTABLO_URL="https://app-staging.xtablo.com"
|
||||
|
||||
CORS_ORIGIN="http://localhost:5173,http://localhost:5174"
|
||||
|
||||
R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
|
||||
R2_ACCESS_KEY_ID="caeb987bbcd601708a93c6aa562064ef"
|
||||
R2_SECRET_ACCESS_KEY="42e455b25804687f7cff3d15be23c1f0f47ca742d7a41b6fa1a05a91041e0215"
|
||||
|
||||
TASKS_SECRET="hello"
|
||||
|
||||
EMAIL_USER="baptiste@xtablo.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,19 +1,13 @@
|
|||
SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
|
||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTI0MTMyMSwiZXhwIjoyMDU2ODE3MzIxfQ.9r33CUsu6ZR4vyv4ed-UY6cLE1FZzSSxTNE8pFUKjN4
|
||||
|
||||
STREAM_CHAT_API_KEY=v4yf8rs94aa8
|
||||
STREAM_CHAT_API_SECRET=jq2szvv73ua7sz9tvr9y24dxg37sw8ue8t576fu7ggr4h6wvcmunby4gvte8tm8f
|
||||
|
||||
XTABLO_URL=https://app.xtablo.com
|
||||
CORS_ORIGIN="https://app.xtablo.com,https://embed.xtablo.com"
|
||||
|
||||
R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
|
||||
R2_ACCESS_KEY_ID="caeb987bbcd601708a93c6aa562064ef"
|
||||
R2_SECRET_ACCESS_KEY="42e455b25804687f7cff3d15be23c1f0f47ca742d7a41b6fa1a05a91041e0215"
|
||||
|
||||
TASKS_SECRET="gT3BAytmNwhe1wKmvgREBlWcqK0="
|
||||
|
||||
EMAIL_USER="baptiste@xtablo.com"
|
||||
EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com"
|
||||
EMAIL_CLIENT_SECRET="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"
|
||||
EMAIL_REFRESH_TOKEN="1//04dRsWFVjr0mqCgYIARAAGAQSNwF-L9IrN3JicCv2ib4F6AQlactB4CE6Q4ST_tEVVdmmECly_-05INeTeqidxmpRHHDJDM8UFBk"
|
||||
EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com"
|
||||
|
|
@ -1,22 +1,11 @@
|
|||
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_SECRET=zrr32sqenw3atpv9rnz2nhhyyncf7bunr7fmfqy9r7e69fcw978dhzevmhpxa2jj
|
||||
|
||||
STRIPE_SECRET_KEY=sk_test_51Qc159AmcXPHW4mTeEs86NXY2lAz6pPKiSteECBTsQ2BmaJxeFkbO4uopoMZM8USggRYJjuwJ4GCXVzy6ROT1hMJ00NJGOUM33
|
||||
STRIPE_WEBHOOK_SECRET=whsec_4c6f3742c4f3760eff1ef974202cb7f27acc93b8a0da6529db7b2ff2d5acec02
|
||||
|
||||
XTABLO_URL="https://app-staging.xtablo.com"
|
||||
CORS_ORIGIN="https://app-staging.xtablo.com"
|
||||
|
||||
R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
|
||||
R2_ACCESS_KEY_ID="caeb987bbcd601708a93c6aa562064ef"
|
||||
R2_SECRET_ACCESS_KEY="42e455b25804687f7cff3d15be23c1f0f47ca742d7a41b6fa1a05a91041e0215"
|
||||
|
||||
EMAIL_USER="baptiste@xtablo.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
1
api/.gitignore
vendored
|
|
@ -17,6 +17,7 @@ node_modules/
|
|||
|
||||
# env
|
||||
.env.development
|
||||
!.env.example
|
||||
.dev.vars
|
||||
|
||||
# logs
|
||||
|
|
|
|||
227
api/package-lock.json
generated
227
api/package-lock.json
generated
|
|
@ -7,6 +7,7 @@
|
|||
"name": "xtablo-api",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@google-cloud/secret-manager": "^6.1.1",
|
||||
"@hono/node-server": "^1.14.4",
|
||||
"@supabase/stripe-sync-engine": "^0.45.0",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
|
|
@ -2226,6 +2227,128 @@
|
|||
"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": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@graphile/logger/-/logger-0.2.0.tgz",
|
||||
|
|
@ -2235,7 +2358,6 @@
|
|||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.0.tgz",
|
||||
"integrity": "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@js-sdsl/ordered-map": "^4.4.2"
|
||||
|
|
@ -2248,7 +2370,6 @@
|
|||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz",
|
||||
"integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"long": "^5.0.0",
|
||||
|
|
@ -2317,7 +2438,6 @@
|
|||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||
|
|
@ -2334,7 +2454,6 @@
|
|||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -2346,7 +2465,6 @@
|
|||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -2357,14 +2475,12 @@
|
|||
"node_modules/@isaacs/cliui/node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
"emoji-regex": "^9.2.2",
|
||||
|
|
@ -2381,7 +2497,6 @@
|
|||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
|
|
@ -2396,7 +2511,6 @@
|
|||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
"string-width": "^5.0.1",
|
||||
|
|
@ -2473,7 +2587,6 @@
|
|||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
|
||||
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/js-sdsl"
|
||||
|
|
@ -2623,7 +2736,6 @@
|
|||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
|
|
@ -3492,7 +3604,6 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
||||
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
|
|
@ -3888,8 +3999,7 @@
|
|||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
|
|
@ -3960,7 +4070,6 @@
|
|||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
|
|
@ -4318,7 +4427,6 @@
|
|||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
|
|
@ -4610,7 +4718,6 @@
|
|||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
||||
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.4.1",
|
||||
"inherits": "^2.0.3",
|
||||
|
|
@ -4621,8 +4728,7 @@
|
|||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
|
|
@ -4648,7 +4754,6 @@
|
|||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
|
|
@ -4993,7 +5098,6 @@
|
|||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
|
|
@ -5009,7 +5113,6 @@
|
|||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
|
|
@ -5945,8 +6048,7 @@
|
|||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
|
||||
},
|
||||
"node_modules/isomorphic-ws": {
|
||||
"version": "5.0.0",
|
||||
|
|
@ -5969,7 +6071,6 @@
|
|||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
|
|
@ -6210,8 +6311,7 @@
|
|||
"node_modules/lodash.camelcase": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
|
|
@ -6379,7 +6479,6 @@
|
|||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
|
|
@ -6636,7 +6735,6 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
|
|
@ -6677,7 +6775,6 @@
|
|||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
|
|
@ -6792,8 +6889,7 @@
|
|||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
|
||||
},
|
||||
"node_modules/package-manager-detector": {
|
||||
"version": "1.5.0",
|
||||
|
|
@ -6848,7 +6944,6 @@
|
|||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
|
|
@ -6857,7 +6952,6 @@
|
|||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.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",
|
||||
"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": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
|
||||
|
|
@ -7474,7 +7615,6 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
|
|
@ -7486,7 +7626,6 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
|
|
@ -7719,7 +7858,6 @@
|
|||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz",
|
||||
"integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"stubs": "^3.0.0"
|
||||
}
|
||||
|
|
@ -7727,8 +7865,7 @@
|
|||
"node_modules/stream-shift": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
|
||||
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
|
|
@ -7764,7 +7901,6 @@
|
|||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
|
|
@ -7790,7 +7926,6 @@
|
|||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
|
|
@ -7843,8 +7978,7 @@
|
|||
"node_modules/stubs": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz",
|
||||
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw=="
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
|
|
@ -8231,7 +8365,6 @@
|
|||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
|
|
@ -8269,7 +8402,6 @@
|
|||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
|
|
@ -8285,8 +8417,7 @@
|
|||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.2",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@google-cloud/secret-manager": "^6.1.1",
|
||||
"@hono/node-server": "^1.14.4",
|
||||
"@supabase/stripe-sync-engine": "^0.45.0",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import dotenv from "dotenv";
|
||||
import type { Secrets } from "./secrets.js";
|
||||
|
||||
export interface AppConfig {
|
||||
NODE_ENV: "development" | "production" | "test" | "staging";
|
||||
|
|
@ -31,12 +32,11 @@ function validateEnvVar(name: string, value: string | undefined): string {
|
|||
return value;
|
||||
}
|
||||
|
||||
function createConfig(): AppConfig {
|
||||
export function createConfig(secrets: Secrets): AppConfig {
|
||||
const NODE_ENV = (process.env.NODE_ENV || "development") as
|
||||
| "development"
|
||||
| "production"
|
||||
| "staging"
|
||||
| "test";
|
||||
| "staging";
|
||||
|
||||
dotenv.config({ path: `.env.${NODE_ENV}` });
|
||||
|
||||
|
|
@ -45,31 +45,22 @@ function createConfig(): AppConfig {
|
|||
NODE_ENV,
|
||||
PORT: parseInt(process.env.PORT || "8080", 10),
|
||||
SUPABASE_URL: validateEnvVar("SUPABASE_URL", process.env.SUPABASE_URL),
|
||||
SUPABASE_SERVICE_ROLE_KEY: validateEnvVar(
|
||||
"SUPABASE_SERVICE_ROLE_KEY",
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
),
|
||||
SUPABASE_CONNECTION_STRING: process.env.SUPABASE_CONNECTION_STRING || "",
|
||||
SUPABASE_CA_CERT: process.env.SUPABASE_CA_CERT || "",
|
||||
SUPABASE_SERVICE_ROLE_KEY: secrets.supabaseServiceRoleKey,
|
||||
SUPABASE_CONNECTION_STRING: secrets.supabaseConnectionString,
|
||||
SUPABASE_CA_CERT: secrets.supabaseCaCert,
|
||||
STREAM_CHAT_API_KEY: validateEnvVar("STREAM_CHAT_API_KEY", process.env.STREAM_CHAT_API_KEY),
|
||||
STREAM_CHAT_API_SECRET: validateEnvVar(
|
||||
"STREAM_CHAT_API_SECRET",
|
||||
process.env.STREAM_CHAT_API_SECRET
|
||||
),
|
||||
STRIPE_SECRET_KEY: validateEnvVar("STRIPE_SECRET_KEY", process.env.STRIPE_SECRET_KEY),
|
||||
STRIPE_WEBHOOK_SECRET: validateEnvVar(
|
||||
"STRIPE_WEBHOOK_SECRET",
|
||||
process.env.STRIPE_WEBHOOK_SECRET
|
||||
),
|
||||
STREAM_CHAT_API_SECRET: secrets.streamChatApiSecret,
|
||||
STRIPE_SECRET_KEY: secrets.stripeSecretKey,
|
||||
STRIPE_WEBHOOK_SECRET: secrets.stripeWebhookSecret,
|
||||
EMAIL_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER),
|
||||
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_REFRESH_TOKEN: validateEnvVar("EMAIL_REFRESH_TOKEN", process.env.EMAIL_REFRESH_TOKEN),
|
||||
EMAIL_CLIENT_SECRET: secrets.emailClientSecret,
|
||||
EMAIL_REFRESH_TOKEN: secrets.emailRefreshToken,
|
||||
CORS_ORIGIN: process.env.CORS_ORIGIN || "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_ACCESS_KEY_ID: validateEnvVar("R2_ACCESS_KEY_ID", process.env.R2_ACCESS_KEY_ID),
|
||||
R2_SECRET_ACCESS_KEY: validateEnvVar("R2_SECRET_ACCESS_KEY", process.env.R2_SECRET_ACCESS_KEY),
|
||||
R2_ACCESS_KEY_ID: secrets.r2AccessKeyId,
|
||||
R2_SECRET_ACCESS_KEY: secrets.r2SecretAccessKey,
|
||||
TASKS_SECRET: process.env.TASKS_SECRET || "",
|
||||
LOG_LEVEL: "info",
|
||||
};
|
||||
|
|
@ -79,26 +70,13 @@ function createConfig(): AppConfig {
|
|||
baseConfig.LOG_LEVEL = "debug";
|
||||
} else if (NODE_ENV === "production") {
|
||||
baseConfig.LOG_LEVEL = "info";
|
||||
} else if (NODE_ENV === "test") {
|
||||
baseConfig.LOG_LEVEL = "warn";
|
||||
}
|
||||
|
||||
// Log configuration info
|
||||
// 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}`);
|
||||
console.log("✓ Configuration loaded successfully");
|
||||
|
||||
return baseConfig;
|
||||
}
|
||||
|
||||
export const config = createConfig();
|
||||
|
||||
// Helper functions for common environment checks
|
||||
export const isDevelopment = () => config.NODE_ENV === "development";
|
||||
export const isProduction = () => config.NODE_ENV === "production";
|
||||
// export const isDevelopment = () => config.NODE_ENV === "development";
|
||||
// export const isProduction = () => config.NODE_ENV === "production";
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
import { ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { EventAndTablo } from "./types.ts";
|
||||
import type { Context, Next } from "hono";
|
||||
import type { EventAndTablo } from "./types.ts";
|
||||
|
||||
export const generateICSFromEvents = (
|
||||
events: EventAndTablo[],
|
||||
|
|
|
|||
116
api/src/index.ts
116
api/src/index.ts
|
|
@ -1,13 +1,18 @@
|
|||
import { serve } from "@hono/node-server";
|
||||
import tracer from "dd-trace";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { logger } from "hono/logger";
|
||||
import path from "path";
|
||||
import Stripe from "stripe";
|
||||
import { fileURLToPath } from "url";
|
||||
import { config } from "./config.js";
|
||||
import { publicRouter } from "./public.js";
|
||||
import { mainRouter } from "./routers.js";
|
||||
import tracer from "dd-trace";
|
||||
import { createConfig } from "./config.js";
|
||||
import { initializeMiddlewares } from "./middleware.js";
|
||||
import { getPublicRouter } from "./public.js";
|
||||
import { getMainRouter } from "./routers.js";
|
||||
import { loadSecrets, type Secrets } from "./secrets.js";
|
||||
import { createStripeSync } from "./stripeSync.js";
|
||||
import { createTransporter } from "./transporter.js";
|
||||
|
||||
tracer.init({
|
||||
logInjection: true,
|
||||
|
|
@ -16,60 +21,69 @@ tracer.init({
|
|||
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 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) => {
|
||||
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,
|
||||
// Initialize Stripe client
|
||||
const stripe = new Stripe(config.STRIPE_SECRET_KEY || "", {
|
||||
apiVersion: "2025-10-29.clover",
|
||||
});
|
||||
|
||||
return corsMiddleware(c, next);
|
||||
});
|
||||
// Initialize Stripe Sync
|
||||
const stripeSync = createStripeSync(config);
|
||||
|
||||
app.route("/api/v1", mainRouter);
|
||||
app.route("/api/public", publicRouter);
|
||||
// Initialize transporter
|
||||
const transporter = createTransporter(config);
|
||||
|
||||
// const worker = async () => {
|
||||
// const connectionString = `${
|
||||
// config.SUPABASE_CONNECTION_STRING
|
||||
// }?ssl=true&sslrootcert=${path.resolve(__dirname, "..", "supabase_ca.crt")}`;
|
||||
const app = new Hono();
|
||||
|
||||
// const runner = await run({
|
||||
// connectionString,
|
||||
// concurrency: 1,
|
||||
// pollInterval: 1000,
|
||||
// taskDirectory: path.resolve(__dirname, "tasks"),
|
||||
// noPreparedStatements: true,
|
||||
// crontabFile: path.resolve(__dirname, "crontab"),
|
||||
// });
|
||||
app.use(logger());
|
||||
|
||||
// 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) => {
|
||||
// console.error(err);
|
||||
// process.exit(1);
|
||||
// });
|
||||
return corsMiddleware(c, next);
|
||||
});
|
||||
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port: 8080,
|
||||
},
|
||||
(info) => {
|
||||
console.log(`Server is running on http://localhost:${info.port}`);
|
||||
}
|
||||
);
|
||||
// TODO: Add health check endpoint
|
||||
app.route("/api/v1", getMainRouter(middlewares, config, stripe, stripeSync, transporter));
|
||||
app.route("/api/public", getPublicRouter(middlewares));
|
||||
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port: 8080,
|
||||
},
|
||||
(info) => {
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,73 +1,109 @@
|
|||
import { S3Client } from "@aws-sdk/client-s3";
|
||||
import { createClient, type User } from "@supabase/supabase-js";
|
||||
import type { Context, Next } from "hono";
|
||||
import { createClient, type SupabaseClient, type User } from "@supabase/supabase-js";
|
||||
import type { Context, MiddlewareHandler, Next } from "hono";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import { StreamChat } from "stream-chat";
|
||||
import { config } from "./config.js";
|
||||
import { type AppConfig } from "./config.js";
|
||||
|
||||
// Create authentication middleware
|
||||
export const authMiddleware = async (c: Context, next: Next) => {
|
||||
const supabase = c.get("supabase");
|
||||
// Extract Bearer token from Authorization header
|
||||
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);
|
||||
}
|
||||
|
||||
const userTyped = user as User;
|
||||
|
||||
c.set("user", userTyped);
|
||||
await next();
|
||||
export type Middlewares = {
|
||||
supabaseMiddleware: MiddlewareHandler<{
|
||||
Variables: { supabase: SupabaseClient };
|
||||
Bindings: { user: User };
|
||||
}>;
|
||||
authMiddleware: MiddlewareHandler<{
|
||||
Variables: { supabase: SupabaseClient; user: User };
|
||||
Bindings: { user: User };
|
||||
}>;
|
||||
streamChatMiddleware: MiddlewareHandler<{
|
||||
Variables: { streamServerClient: StreamChat };
|
||||
}>;
|
||||
r2Middleware: MiddlewareHandler<{
|
||||
Variables: { s3_client: S3Client };
|
||||
}>;
|
||||
regularUserCheckMiddleware: MiddlewareHandler<{
|
||||
Variables: { supabase: SupabaseClient; user: User };
|
||||
Bindings: { user: User };
|
||||
}>;
|
||||
};
|
||||
|
||||
export const regularUserCheckMiddleware = async (c: Context, next: Next) => {
|
||||
const user = c.get("user");
|
||||
if (user.is_temporary) {
|
||||
return c.json({ error: "User is read only" }, 401);
|
||||
}
|
||||
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,
|
||||
},
|
||||
export const initializeMiddlewares = (config: AppConfig): Middlewares => {
|
||||
const supabaseMiddleware = createMiddleware(async (c: Context, next: Next) => {
|
||||
const supabase = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY);
|
||||
c.set("supabase", supabase);
|
||||
await next();
|
||||
});
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
110
api/src/notes.ts
110
api/src/notes.ts
|
|
@ -1,54 +1,55 @@
|
|||
import type { SupabaseClient, User } from "@supabase/supabase-js";
|
||||
import { Hono } from "hono";
|
||||
import { checkTabloMember } from "./helpers.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<{
|
||||
Variables: {
|
||||
user: User;
|
||||
supabase: SupabaseClient;
|
||||
};
|
||||
}>();
|
||||
export const getNotesRouter = (middlewares: Middlewares) => {
|
||||
const notesRouter = new Hono<{
|
||||
Variables: {
|
||||
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
|
||||
*/
|
||||
notesRouter.get("/:tabloId", checkTabloMember, async (c) => {
|
||||
const { tabloId } = c.req.param();
|
||||
/**
|
||||
* Fetch notes shared with a specific tablo
|
||||
*/
|
||||
notesRouter.get("/:tabloId", checkTabloMember, async (c) => {
|
||||
const { tabloId } = c.req.param();
|
||||
|
||||
if (!tabloId) {
|
||||
return c.json({ error: "Tablo ID is required" }, 400);
|
||||
}
|
||||
if (!tabloId) {
|
||||
return c.json({ error: "Tablo ID is required" }, 400);
|
||||
}
|
||||
|
||||
const supabase = c.get("supabase");
|
||||
const supabase = c.get("supabase");
|
||||
|
||||
// Find the tablo owner
|
||||
const { data: tabloData, error: tabloError } = await supabase
|
||||
.from("tablos")
|
||||
.select("owner_id")
|
||||
.eq("id", tabloId)
|
||||
.single();
|
||||
// Find the tablo owner
|
||||
const { data: tabloData, error: tabloError } = await supabase
|
||||
.from("tablos")
|
||||
.select("owner_id")
|
||||
.eq("id", tabloId)
|
||||
.single();
|
||||
|
||||
if (tabloError) {
|
||||
console.error("Error fetching tablo:", tabloError);
|
||||
return c.json({ error: "Failed to fetch tablo" }, 500);
|
||||
}
|
||||
if (tabloError) {
|
||||
console.error("Error fetching tablo:", tabloError);
|
||||
return c.json({ error: "Failed to fetch tablo" }, 500);
|
||||
}
|
||||
|
||||
if (!tabloData) {
|
||||
return c.json({ error: "Tablo not found" }, 404);
|
||||
}
|
||||
if (!tabloData) {
|
||||
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
|
||||
const { data, error } = await supabase
|
||||
.from("note_access")
|
||||
.select(`
|
||||
// Find notes shared with this specific tablo or all tablos
|
||||
const { data, error } = await supabase
|
||||
.from("note_access")
|
||||
.select(`
|
||||
note_id,
|
||||
notes!inner (
|
||||
id,
|
||||
|
|
@ -60,23 +61,26 @@ notesRouter.get("/:tabloId", checkTabloMember, async (c) => {
|
|||
deleted_at
|
||||
)
|
||||
`)
|
||||
.eq("is_active", true)
|
||||
.eq("user_id", tabloOwnerId)
|
||||
.or(`tablo_id.eq.${tabloId},tablo_id.is.null`)
|
||||
.is("notes.deleted_at", null);
|
||||
.eq("is_active", true)
|
||||
.eq("user_id", tabloOwnerId)
|
||||
.or(`tablo_id.eq.${tabloId},tablo_id.is.null`)
|
||||
.is("notes.deleted_at", null);
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: "An error occurred" }, 500);
|
||||
}
|
||||
if (error) {
|
||||
return c.json({ error: "An error occurred" }, 500);
|
||||
}
|
||||
|
||||
// Extract notes from the join result and remove duplicates
|
||||
type JoinedResult = { note_id: string; notes: Note[] };
|
||||
const extractedNotes = (data as JoinedResult[])
|
||||
.map((item) => (Array.isArray(item.notes) ? item.notes[0] : item.notes))
|
||||
.filter((note) => note !== null && note !== undefined);
|
||||
// Extract notes from the join result and remove duplicates
|
||||
type JoinedResult = { note_id: string; notes: Note[] };
|
||||
const extractedNotes = (data as JoinedResult[])
|
||||
.map((item) => (Array.isArray(item.notes) ? item.notes[0] : item.notes))
|
||||
.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)
|
||||
const uniqueNotes = Array.from(new Map(extractedNotes.map((note) => [note.id, note])).values());
|
||||
// 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());
|
||||
|
||||
return c.json({ notes: uniqueNotes });
|
||||
});
|
||||
return c.json({ notes: uniqueNotes });
|
||||
});
|
||||
|
||||
return notesRouter;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { Hono } from "hono";
|
||||
import type { Database, Tables } from "./database.types.js";
|
||||
import { supabaseMiddleware } from "./middleware.js";
|
||||
import type { Middlewares } from "./middleware.js";
|
||||
import {
|
||||
type EventTypeConfig,
|
||||
type Exception,
|
||||
|
|
@ -12,119 +12,123 @@ import {
|
|||
type WeeklyAvailability,
|
||||
} from "./slots.js";
|
||||
|
||||
export const publicRouter = new Hono<{
|
||||
Variables: {
|
||||
supabase: SupabaseClient;
|
||||
};
|
||||
}>();
|
||||
export const getPublicRouter = (middlewares: Middlewares) => {
|
||||
const publicRouter = new Hono<{
|
||||
Variables: {
|
||||
supabase: SupabaseClient;
|
||||
};
|
||||
}>();
|
||||
|
||||
publicRouter.use(supabaseMiddleware);
|
||||
publicRouter.use(middlewares.supabaseMiddleware);
|
||||
|
||||
publicRouter.get("/slots/:shortUserId/:standardName", async (c) => {
|
||||
const supabase = c.get("supabase");
|
||||
const shortUserId = c.req.param("shortUserId");
|
||||
const standardName = c.req.param("standardName");
|
||||
publicRouter.get("/slots/:shortUserId/:standardName", async (c) => {
|
||||
const supabase = c.get("supabase");
|
||||
const shortUserId = c.req.param("shortUserId");
|
||||
const standardName = c.req.param("standardName");
|
||||
|
||||
// Get user
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from("profiles")
|
||||
.select("*")
|
||||
.eq("short_user_id", shortUserId)
|
||||
.single();
|
||||
// Get user
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from("profiles")
|
||||
.select("*")
|
||||
.eq("short_user_id", shortUserId)
|
||||
.single();
|
||||
|
||||
if (userError || !userData) {
|
||||
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);
|
||||
if (userError || !userData) {
|
||||
return c.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
const user = userData as Tables<"profiles">;
|
||||
|
||||
// Group slots by date for easier frontend consumption
|
||||
const slotsByDate: { [date: string]: TimeSlot[] } = {};
|
||||
slots.forEach((slot) => {
|
||||
if (!slotsByDate[slot.date]) {
|
||||
slotsByDate[slot.date] = [];
|
||||
// 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);
|
||||
}
|
||||
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({
|
||||
user: { name: user.name, avatar_url: user.avatar_url },
|
||||
eventType: eventTypeConfig,
|
||||
slots: slotsByDate,
|
||||
availableSlots: slots.filter((slot) => slot.available),
|
||||
});
|
||||
});
|
||||
return publicRouter;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,42 +1,39 @@
|
|||
import type { StripeSync } from "@supabase/stripe-sync-engine";
|
||||
import { Hono } from "hono";
|
||||
import { supabaseMiddleware } from "./middleware.js";
|
||||
import { tabloRouter } from "./tablo.js";
|
||||
import { tabloDataRouter } from "./tablo_data.js";
|
||||
import { taskRouter } from "./tasks.js";
|
||||
import { userRouter } from "./user.js";
|
||||
import { notesRouter } from "./notes.js";
|
||||
import { stripeRouter, stripeWebhookRouter } from "./stripe.js";
|
||||
import type { Transporter } from "nodemailer";
|
||||
import type Stripe from "stripe";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import type { Middlewares } from "./middleware.js";
|
||||
import { getNotesRouter } from "./notes.js";
|
||||
import { getStripeRouter, getStripeWebhookRouter } 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<{
|
||||
Bindings: {
|
||||
SESSION_ENCRYPTION_KEY: string;
|
||||
};
|
||||
}>();
|
||||
export const getMainRouter = (
|
||||
middlewares: Middlewares,
|
||||
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.use("*", (c, next) =>
|
||||
// sessionMiddleware({
|
||||
// store,
|
||||
// encryptionKey: c.env.SESSION_ENCRYPTION_KEY,
|
||||
// expireAfterSeconds: 900,
|
||||
// sessionCookieName: "xtablo_session",
|
||||
// cookieOptions: {
|
||||
// sameSite: "Lax",
|
||||
// path: "/",
|
||||
// httpOnly: true,
|
||||
// secure: false,
|
||||
// // secure: process.env.NODE_ENV === "production",
|
||||
// },
|
||||
// })(c, next)
|
||||
// );
|
||||
mainRouter.route("/users", getUserRouter(middlewares, transporter));
|
||||
mainRouter.route("/tablos", getTabloRouter(middlewares, config, transporter));
|
||||
mainRouter.route("/tasks", getTaskRouter(middlewares, config, stripeSync));
|
||||
mainRouter.route("/tablo-data", getTabloDataRouter(middlewares));
|
||||
mainRouter.route("/notes", getNotesRouter(middlewares));
|
||||
// stripe routes
|
||||
mainRouter.route("/stripe", getStripeRouter(middlewares, config, stripe));
|
||||
mainRouter.route("/stripe-webhook", getStripeWebhookRouter(stripeSync));
|
||||
|
||||
mainRouter.route("/users", userRouter);
|
||||
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);
|
||||
return mainRouter;
|
||||
};
|
||||
|
|
|
|||
51
api/src/secrets.ts
Normal file
51
api/src/secrets.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 { Hono } from "hono";
|
||||
import Stripe from "stripe";
|
||||
import { authMiddleware, regularUserCheckMiddleware } from "./middleware.js";
|
||||
import { config } from "./config.js";
|
||||
import { stripeSync } from "./stripeSync.js";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import type { Middlewares } from "./middleware.js";
|
||||
|
||||
const stripe = new Stripe(config.STRIPE_SECRET_KEY || "", {
|
||||
apiVersion: "2025-10-29.clover",
|
||||
});
|
||||
export const getStripeWebhookRouter = (stripeSync: StripeSync) => {
|
||||
const stripeWebhookRouter = new Hono();
|
||||
|
||||
export const stripeRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
supabase: SupabaseClient;
|
||||
};
|
||||
}>();
|
||||
/**
|
||||
* Stripe webhook handler using @supabase/stripe-sync-engine
|
||||
* This automatically syncs all Stripe events to Supabase tables
|
||||
* 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);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Webhook endpoint (no auth required - validated by signature)
|
||||
// ============================================================================
|
||||
// Get raw body for signature verification
|
||||
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);
|
||||
|
||||
/**
|
||||
* Stripe webhook handler using @supabase/stripe-sync-engine
|
||||
* This automatically syncs all Stripe events to Supabase tables
|
||||
* Repository: https://github.com/supabase/stripe-sync-engine
|
||||
*/
|
||||
stripeWebhookRouter.post("/", async (c) => {
|
||||
try {
|
||||
const signature = c.req.header("stripe-signature");
|
||||
|
||||
if (!signature) {
|
||||
return c.json({ error: "No signature provided" }, 400);
|
||||
return c.json({ received: true });
|
||||
} catch (error) {
|
||||
console.error("Webhook error:", error);
|
||||
return c.json(
|
||||
{ error: error instanceof Error ? error.message : "Webhook processing failed" },
|
||||
400
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Get raw body for signature verification
|
||||
const rawBody = await c.req.text();
|
||||
return stripeWebhookRouter;
|
||||
};
|
||||
|
||||
// Process webhook using Stripe Sync Engine
|
||||
// This handles signature verification and syncing automatically
|
||||
await stripeSync.processWebhook(rawBody, signature);
|
||||
export const getStripeRouter = (middlewares: Middlewares, config: AppConfig, stripe: Stripe) => {
|
||||
const stripeRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
supabase: SupabaseClient;
|
||||
};
|
||||
}>();
|
||||
|
||||
return c.json({ received: true });
|
||||
} catch (error) {
|
||||
console.error("Webhook error:", error);
|
||||
return c.json(
|
||||
{ error: error instanceof Error ? error.message : "Webhook processing failed" },
|
||||
400
|
||||
);
|
||||
}
|
||||
});
|
||||
stripeRouter.use(middlewares.authMiddleware);
|
||||
|
||||
// ============================================================================
|
||||
// Authenticated endpoints
|
||||
// ============================================================================
|
||||
// ============================================================================
|
||||
// Authenticated endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a Stripe Checkout Session
|
||||
* POST /api/v1/stripe/create-checkout-session
|
||||
*/
|
||||
stripeRouter.post("/create-checkout-session", regularUserCheckMiddleware, async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const body = await c.req.json();
|
||||
const { priceId, successUrl, cancelUrl } = body;
|
||||
/**
|
||||
* Create a Stripe Checkout Session
|
||||
* POST /api/v1/stripe/create-checkout-session
|
||||
*/
|
||||
stripeRouter.post(
|
||||
"/create-checkout-session",
|
||||
middlewares.regularUserCheckMiddleware,
|
||||
async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const body = await c.req.json();
|
||||
const { priceId, successUrl, cancelUrl } = body;
|
||||
|
||||
if (!priceId) {
|
||||
return c.json({ error: "priceId is required" }, 400);
|
||||
}
|
||||
if (!priceId) {
|
||||
return c.json({ error: "priceId is required" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get or create Stripe customer
|
||||
let customerId: string;
|
||||
try {
|
||||
// Get or create Stripe customer
|
||||
let customerId: string;
|
||||
|
||||
// Check if customer already exists by querying stripe schema with metadata filter
|
||||
// Note: Using service role, so we filter manually by metadata
|
||||
const { data: customers } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata")
|
||||
.limit(1000); // Get all customers to filter by metadata
|
||||
// Check if customer already exists by querying stripe schema with metadata filter
|
||||
// Note: Using service role, so we filter manually by metadata
|
||||
const { data: customers } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata")
|
||||
.limit(1000); // Get all customers to filter by metadata
|
||||
|
||||
const existingCustomer = customers?.find(
|
||||
(c: Stripe.Customer) => c.metadata?.user_id === user.id
|
||||
);
|
||||
const existingCustomer = customers?.find(
|
||||
(c: Stripe.Customer) => c.metadata?.user_id === user.id
|
||||
);
|
||||
|
||||
if (existingCustomer) {
|
||||
customerId = existingCustomer.id;
|
||||
} else {
|
||||
// Create new Stripe customer with user_id in metadata
|
||||
// stripe-sync-engine will automatically sync this to the database via webhook
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email!,
|
||||
metadata: {
|
||||
user_id: user.id, // Stored in metadata for tracking
|
||||
},
|
||||
if (existingCustomer) {
|
||||
customerId = existingCustomer.id;
|
||||
} else {
|
||||
// Create new Stripe customer with user_id in metadata
|
||||
// stripe-sync-engine will automatically sync this to the database via webhook
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email!,
|
||||
metadata: {
|
||||
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
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Note: Subscription status queries are handled directly from the frontend
|
||||
// using Supabase client with RLS policies. No API endpoints needed for reads.
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Cancel subscription at period end
|
||||
* POST /api/v1/stripe/cancel-subscription
|
||||
*/
|
||||
stripeRouter.post("/cancel-subscription", middlewares.regularUserCheckMiddleware, async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
|
||||
/**
|
||||
* Create a Stripe Customer Portal Session
|
||||
* POST /api/v1/stripe/create-portal-session
|
||||
*/
|
||||
stripeRouter.post("/create-portal-session", regularUserCheckMiddleware, async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const body = await c.req.json();
|
||||
const { returnUrl } = body;
|
||||
try {
|
||||
// Get user's Stripe customer first
|
||||
const { data: customers } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata");
|
||||
|
||||
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);
|
||||
|
||||
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) {
|
||||
return c.json({ error: "No Stripe customer found" }, 404);
|
||||
// Get user's active subscription for this customer
|
||||
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({
|
||||
customer: customer.id,
|
||||
return_url: returnUrl || `${process.env.FRONTEND_URL}/settings`,
|
||||
});
|
||||
/**
|
||||
* Reactivate a canceled subscription
|
||||
* POST /api/v1/stripe/reactivate-subscription
|
||||
*/
|
||||
stripeRouter.post(
|
||||
"/reactivate-subscription",
|
||||
middlewares.regularUserCheckMiddleware,
|
||||
async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
});
|
||||
try {
|
||||
// Get user's Stripe customer first
|
||||
const { data: customers } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata");
|
||||
|
||||
// Note: Subscription status queries are handled directly from the frontend
|
||||
// using Supabase client with RLS policies. No API endpoints needed for reads.
|
||||
const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id);
|
||||
|
||||
/**
|
||||
* Cancel subscription at period end
|
||||
* POST /api/v1/stripe/cancel-subscription
|
||||
*/
|
||||
stripeRouter.post("/cancel-subscription", regularUserCheckMiddleware, async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
if (!customer) {
|
||||
return c.json({ error: "No subscription found to reactivate" }, 404);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user's Stripe customer first
|
||||
const { data: customers } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata");
|
||||
// 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();
|
||||
|
||||
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) {
|
||||
return c.json({ error: "Customer not found" }, 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
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get user's active subscription for this customer
|
||||
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
|
||||
);
|
||||
}
|
||||
});
|
||||
return stripeRouter;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
import { StripeSync } from "@supabase/stripe-sync-engine";
|
||||
import { config } from "./config.js";
|
||||
import type { AppConfig } from "./config.js";
|
||||
|
||||
const ssl = {
|
||||
ca: Buffer.from(config.SUPABASE_CA_CERT, "base64").toString("utf-8"),
|
||||
export const createStripeSync = (config: AppConfig): StripeSync => {
|
||||
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"],
|
||||
});
|
||||
|
|
|
|||
1198
api/src/tablo.ts
1198
api/src/tablo.ts
File diff suppressed because it is too large
Load diff
|
|
@ -2,178 +2,177 @@ import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
|
|||
import type { SupabaseClient, User } from "@supabase/supabase-js";
|
||||
import { Hono } from "hono";
|
||||
import { checkTabloAdmin, checkTabloMember, getTabloFileNames } from "./helpers.js";
|
||||
import {
|
||||
authMiddleware,
|
||||
r2Middleware,
|
||||
regularUserCheckMiddleware,
|
||||
streamChatMiddleware,
|
||||
} from "./middleware.js";
|
||||
import type { Middlewares } from "./middleware.js";
|
||||
|
||||
export const tabloDataRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
supabase: SupabaseClient;
|
||||
s3_client: S3Client;
|
||||
};
|
||||
}>();
|
||||
export const getTabloDataRouter = (middlewares: Middlewares) => {
|
||||
const tabloDataRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
supabase: SupabaseClient;
|
||||
s3_client: S3Client;
|
||||
};
|
||||
}>();
|
||||
|
||||
tabloDataRouter.use(authMiddleware);
|
||||
tabloDataRouter.use(streamChatMiddleware);
|
||||
tabloDataRouter.use(r2Middleware);
|
||||
tabloDataRouter.use(middlewares.authMiddleware);
|
||||
tabloDataRouter.use(middlewares.streamChatMiddleware);
|
||||
tabloDataRouter.use(middlewares.r2Middleware);
|
||||
|
||||
// GET /tablo-data/:tabloId/filenames - Get all files for a tablo
|
||||
tabloDataRouter.get("/:tabloId/filenames", checkTabloMember, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const s3_client = c.get("s3_client");
|
||||
// GET /tablo-data/:tabloId/filenames - Get all files for a tablo
|
||||
tabloDataRouter.get("/:tabloId/filenames", checkTabloMember, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const s3_client = c.get("s3_client");
|
||||
|
||||
try {
|
||||
const fileNames = await getTabloFileNames(s3_client, tabloId);
|
||||
return c.json({ fileNames: fileNames || [] });
|
||||
} catch (error) {
|
||||
console.error("Error fetching tablo files:", error);
|
||||
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);
|
||||
try {
|
||||
const fileNames = await getTabloFileNames(s3_client, tabloId);
|
||||
return c.json({ fileNames: fileNames || [] });
|
||||
} catch (error) {
|
||||
console.error("Error fetching tablo files:", error);
|
||||
return c.json({ error: "Failed to fetch tablo files" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
const content = await response.Body.transformToString();
|
||||
|
||||
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) => {
|
||||
// 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 body = await c.req.json();
|
||||
const { content, contentType = "text/plain" } = body;
|
||||
const { GetObjectCommand } = await import("@aws-sdk/client-s3");
|
||||
|
||||
if (!content) {
|
||||
return c.json({ error: "Content is required" }, 400);
|
||||
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);
|
||||
}
|
||||
|
||||
await s3_client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "tablo-data",
|
||||
Key: `${tabloId}/${fileName}`,
|
||||
Body: content,
|
||||
ContentType: contentType,
|
||||
})
|
||||
);
|
||||
const content = await response.Body.transformToString();
|
||||
|
||||
return c.json({
|
||||
message: "File uploaded successfully",
|
||||
fileName,
|
||||
tabloId,
|
||||
content,
|
||||
contentType: response.ContentType,
|
||||
lastModified: response.LastModified,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
return c.json({ error: "Failed to upload file" }, 500);
|
||||
console.error("Error fetching file:", error);
|
||||
return c.json({ error: "Failed to fetch 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");
|
||||
// POST /tablo-data/:tabloId/:fileName - Create or update a file
|
||||
tabloDataRouter.post(
|
||||
"/:tabloId/:fileName",
|
||||
middlewares.regularUserCheckMiddleware,
|
||||
checkTabloMember,
|
||||
async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const fileName = c.req.param("fileName");
|
||||
|
||||
// try {
|
||||
// const body = await c.req.json();
|
||||
// const { content, contentType = "text/plain" } = body;
|
||||
const s3_client = c.get("s3_client");
|
||||
|
||||
// if (!content) {
|
||||
// return c.json({ error: "Content is required" }, 400);
|
||||
// }
|
||||
try {
|
||||
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(
|
||||
// new PutObjectCommand({
|
||||
// Bucket: "tablo-data",
|
||||
// Key: `${tabloId}/${fileName}`,
|
||||
// Body: content,
|
||||
// ContentType: contentType,
|
||||
// })
|
||||
// );
|
||||
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",
|
||||
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 c.json({
|
||||
message: "File uploaded successfully",
|
||||
fileName,
|
||||
tabloId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
return c.json({ error: "Failed to upload 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;
|
||||
};
|
||||
|
|
|
|||
176
api/src/tasks.ts
176
api/src/tasks.ts
|
|
@ -1,105 +1,113 @@
|
|||
import { S3Client } from "@aws-sdk/client-s3";
|
||||
import type { StripeSync } from "@supabase/stripe-sync-engine";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { Hono, type Context } from "hono";
|
||||
import { config } from "./config.js";
|
||||
import { writeCalendarFileToR2 } from "./helpers.js";
|
||||
import { streamChatMiddleware } from "./middleware.js";
|
||||
import { type Context, Hono } from "hono";
|
||||
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<{
|
||||
Variables: { supabase: SupabaseClient };
|
||||
}>();
|
||||
export const getTaskRouter = (
|
||||
middlewares: Middlewares,
|
||||
config: AppConfig,
|
||||
stripeSync: StripeSync
|
||||
) => {
|
||||
const taskRouter = new Hono<{
|
||||
Variables: { supabase: SupabaseClient };
|
||||
}>();
|
||||
|
||||
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 } }>
|
||||
) => {
|
||||
taskRouter.post("/sync-calendars", async (c) => {
|
||||
const supabase = c.get("supabase");
|
||||
const streamServerClient = c.get("streamServerClient");
|
||||
|
||||
if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) {
|
||||
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
|
||||
.from("tablos")
|
||||
.select("id, name")
|
||||
.gt("updated_at", new Date(Date.now() - fifteenMinutesInMilliseconds).toISOString());
|
||||
|
||||
.from("calendar_subscriptions")
|
||||
.select("token, tablo_id, tablos(name)");
|
||||
if (error) {
|
||||
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) => {
|
||||
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}`);
|
||||
}
|
||||
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 ${tablosData.length} tablo names` });
|
||||
}
|
||||
);
|
||||
return c.json({ message: "Synced calendars" });
|
||||
});
|
||||
|
||||
taskRouter.post("/sync-stripe-subscriptions", async (c) => {
|
||||
if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
taskRouter.post(
|
||||
"/sync-tablo-names",
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { google } from "googleapis";
|
||||
import nodemailer from "nodemailer";
|
||||
import { config } from "./config.js";
|
||||
import type { AppConfig } from "./config.js";
|
||||
|
||||
const OAuth2 = google.auth.OAuth2;
|
||||
|
||||
export const createTransporter = () => {
|
||||
export const createTransporter = (config: AppConfig) => {
|
||||
const oauth2Client = new OAuth2(
|
||||
config.EMAIL_CLIENT_ID,
|
||||
config.EMAIL_CLIENT_SECRET,
|
||||
|
|
@ -30,5 +30,3 @@ export const createTransporter = () => {
|
|||
|
||||
return transporter;
|
||||
};
|
||||
|
||||
export const transporter = createTransporter();
|
||||
|
|
|
|||
380
api/src/user.ts
380
api/src/user.ts
|
|
@ -9,96 +9,95 @@ import { Hono } from "hono";
|
|||
import type { Transporter } from "nodemailer";
|
||||
import { StreamChat } from "stream-chat";
|
||||
import type { Tables } from "./database.types.ts";
|
||||
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
|
||||
import { transporter } from "./transporter.js";
|
||||
import type { Middlewares } from "./middleware.js";
|
||||
|
||||
export const userRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
supabase: SupabaseClient;
|
||||
transporter: Transporter;
|
||||
streamServerClient: StreamChat;
|
||||
s3_client: S3Client;
|
||||
};
|
||||
}>();
|
||||
export const getUserRouter = (middlewares: Middlewares, transporter: Transporter) => {
|
||||
const userRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
supabase: SupabaseClient;
|
||||
streamServerClient: StreamChat;
|
||||
s3_client: S3Client;
|
||||
};
|
||||
}>();
|
||||
|
||||
userRouter.use(authMiddleware);
|
||||
userRouter.use(streamChatMiddleware);
|
||||
userRouter.use(r2Middleware);
|
||||
userRouter.use(middlewares.authMiddleware);
|
||||
userRouter.use(middlewares.streamChatMiddleware);
|
||||
userRouter.use(middlewares.r2Middleware);
|
||||
|
||||
userRouter.post("/sign-up-to-stream", async (c) => {
|
||||
const { id } = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
userRouter.post("/sign-up-to-stream", async (c) => {
|
||||
const { id } = c.get("user");
|
||||
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");
|
||||
await streamServerClient.upsertUser({
|
||||
id,
|
||||
name: user.name ?? "",
|
||||
language: "fr",
|
||||
const streamServerClient = c.get("streamServerClient");
|
||||
await streamServerClient.upsertUser({
|
||||
id,
|
||||
name: user.name ?? "",
|
||||
language: "fr",
|
||||
});
|
||||
|
||||
return c.json({
|
||||
message: "User signed up to stream",
|
||||
});
|
||||
});
|
||||
|
||||
return c.json({
|
||||
message: "User signed up to stream",
|
||||
userRouter.get("/me", async (c) => {
|
||||
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) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const streamServerClient = c.get("streamServerClient");
|
||||
userRouter.post("/mark-temporary", async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
|
||||
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) {
|
||||
return c.json({ error: "User not found" }, 404);
|
||||
}
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
}
|
||||
|
||||
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.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 !
|
||||
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 été 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,
|
||||
L'équipe XTablo`,
|
||||
html: `
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #333;">Bienvenue sur XTablo !</h2>
|
||||
|
||||
|
|
@ -137,143 +136,146 @@ L'équipe XTablo`,
|
|||
</p>
|
||||
</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({
|
||||
message: "User marked as temporary",
|
||||
return c.json({
|
||||
message: "User marked as temporary",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// userRouter.put("/profile", async (c) => {
|
||||
// const user = c.get("user");
|
||||
// const supabase = c.get("supabase");
|
||||
// userRouter.put("/profile", async (c) => {
|
||||
// const user = c.get("user");
|
||||
// const supabase = c.get("supabase");
|
||||
|
||||
// const body = await c.req.json();
|
||||
// const { first_name, last_name } = body;
|
||||
// const body = await c.req.json();
|
||||
// const { first_name, last_name } = body;
|
||||
|
||||
// // Deprecated: name field is deprecated, use first_name and last_name instead
|
||||
// // Combine first_name and last_name into a single name field
|
||||
// const name = [first_name, last_name].filter(Boolean).join(" ");
|
||||
// // Deprecated: name field is deprecated, use first_name and last_name instead
|
||||
// // Combine first_name and last_name into a single name field
|
||||
// const name = [first_name, last_name].filter(Boolean).join(" ");
|
||||
|
||||
// const updateData =
|
||||
// first_name && last_name
|
||||
// ? {
|
||||
// name,
|
||||
// first_name,
|
||||
// last_name,
|
||||
// }
|
||||
// : {};
|
||||
// const updateData =
|
||||
// first_name && last_name
|
||||
// ? {
|
||||
// name,
|
||||
// first_name,
|
||||
// last_name,
|
||||
// }
|
||||
// : {};
|
||||
|
||||
// const { data: profile, error } = await supabase
|
||||
// .from("profiles")
|
||||
// .update(updateData)
|
||||
// .eq("id", user.id)
|
||||
// .select()
|
||||
// .single();
|
||||
// const { data: profile, error } = await supabase
|
||||
// .from("profiles")
|
||||
// .update(updateData)
|
||||
// .eq("id", user.id)
|
||||
// .select()
|
||||
// .single();
|
||||
|
||||
// if (error) {
|
||||
// return c.json({ error: error.message }, 500);
|
||||
// }
|
||||
// if (error) {
|
||||
// return c.json({ error: error.message }, 500);
|
||||
// }
|
||||
|
||||
// return c.json({
|
||||
// message: "Profile updated successfully",
|
||||
// profile,
|
||||
// });
|
||||
// });
|
||||
// return c.json({
|
||||
// message: "Profile updated successfully",
|
||||
// profile,
|
||||
// });
|
||||
// });
|
||||
|
||||
userRouter.post("/profile/avatar", async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const s3Client = c.get("s3_client");
|
||||
userRouter.post("/profile/avatar", async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const s3Client = c.get("s3_client");
|
||||
|
||||
const body = await c.req.json();
|
||||
const { content, contentType = "image/jpeg" } = body;
|
||||
const body = await c.req.json();
|
||||
const { content, contentType = "image/jpeg" } = body;
|
||||
|
||||
if (!content) {
|
||||
return c.json({ error: "Content is required" }, 400);
|
||||
}
|
||||
if (!content) {
|
||||
return c.json({ error: "Content is required" }, 400);
|
||||
}
|
||||
|
||||
const randomString = Math.random().toString(36).substring(2, 15);
|
||||
const base64Content = Buffer.from(content, "base64");
|
||||
const key = `${user.id}/public_avatar_${randomString}.${contentType.split("/")[1]}`;
|
||||
const randomString = Math.random().toString(36).substring(2, 15);
|
||||
const base64Content = Buffer.from(content, "base64");
|
||||
const key = `${user.id}/public_avatar_${randomString}.${contentType.split("/")[1]}`;
|
||||
|
||||
try {
|
||||
await s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "web-assets",
|
||||
Key: key,
|
||||
Body: base64Content,
|
||||
ContentType: contentType,
|
||||
ContentEncoding: "base64",
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to upload avatar:", error);
|
||||
return c.json({ error: "Failed to upload avatar" }, 500);
|
||||
}
|
||||
try {
|
||||
await s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "web-assets",
|
||||
Key: key,
|
||||
Body: base64Content,
|
||||
ContentType: contentType,
|
||||
ContentEncoding: "base64",
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to upload avatar:", error);
|
||||
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
|
||||
.from("profiles")
|
||||
.update({ avatar_url: avatarUrl })
|
||||
.eq("id", user.id)
|
||||
.select()
|
||||
.single();
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.update({ avatar_url: avatarUrl })
|
||||
.eq("id", user.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
}
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "Avatar updated successfully",
|
||||
profile: data,
|
||||
return c.json({
|
||||
message: "Avatar updated successfully",
|
||||
profile: data,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
userRouter.delete("/profile/avatar", async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const s3Client = c.get("s3_client");
|
||||
userRouter.delete("/profile/avatar", async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const s3Client = c.get("s3_client");
|
||||
|
||||
try {
|
||||
const listedObjects = await s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: "web-assets",
|
||||
Prefix: `${user.id}/`,
|
||||
})
|
||||
);
|
||||
try {
|
||||
const listedObjects = await s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: "web-assets",
|
||||
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(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: "web-assets",
|
||||
Delete: { Objects: listedObjects.Contents.map(({ Key }) => ({ Key })) },
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete avatar:", error);
|
||||
return c.json({ error: "Failed to delete avatar" }, 500);
|
||||
}
|
||||
await s3Client.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: "web-assets",
|
||||
Delete: { Objects: listedObjects.Contents.map(({ Key }) => ({ Key })) },
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete avatar:", error);
|
||||
return c.json({ error: "Failed to delete avatar" }, 500);
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("profiles")
|
||||
.update({ avatar_url: null })
|
||||
.eq("id", user.id)
|
||||
.select()
|
||||
.single();
|
||||
const { error } = await supabase
|
||||
.from("profiles")
|
||||
.update({ avatar_url: null })
|
||||
.eq("id", user.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
}
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "Avatar deleted successfully",
|
||||
return c.json({
|
||||
message: "Avatar deleted successfully",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return userRouter;
|
||||
};
|
||||
|
|
|
|||
299
docs/GOOGLE_SECRET_MANAGER_SETUP.md
Normal file
299
docs/GOOGLE_SECRET_MANAGER_SETUP.md
Normal 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
|
||||
|
||||
Loading…
Reference in a new issue