security: sanitized baseline for public release

This commit is contained in:
Patrick Britton 2026-02-15 00:33:24 -06:00
commit 434937961c
654 changed files with 136822 additions and 0 deletions

View file

@ -0,0 +1,52 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(supabase login:*)",
"Bash(npm install:*)",
"Bash(curl:*)",
"Bash(flutter --version:*)",
"Bash(flutter create:*)",
"Bash(flutter pub get:*)",
"Bash(flutter analyze:*)",
"Bash(npx supabase db execute:*)",
"Bash(supabase status:*)",
"Bash(dir:*)",
"Bash(powershell -ExecutionPolicy Bypass -File test_edge_functions.ps1)",
"Bash(dart run:*)",
"Bash(flutter devices:*)",
"Bash(flutter uninstall:*)",
"Bash(adb:*)",
"Bash(flutter install:*)",
"Bash(powershell -ExecutionPolicy Bypass -File run_dev.ps1)",
"Bash(powershell:*)",
"Bash(timeout 5 cat:*)",
"Bash(supabase:*)",
"Bash(findstr:*)",
"Bash(npx supabase:*)",
"Bash(head:*)",
"Bash(cat:*)",
"Bash(grep:*)",
"Bash(ls:*)",
"Bash(flutter pub add:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(flutter run:*)",
"Bash(timeout 15 tail:*)",
"Bash(nslookup:*)",
"Bash(flutter logs:*)",
"Bash(flutter pub deps:*)",
"Bash(jq:*)",
"Bash(git push:*)",
"Bash(psql:*)",
"Bash(where:*)",
"Bash(python -c:*)",
"Bash(powershell.exe:*)",
"Bash(pwsh.exe -ExecutionPolicy Bypass -File ./deploy_all_functions.ps1)",
"Bash(flutter build:*)",
"Bash(find:*)",
"Bash(flutter upgrade:*)",
"Bash(xargs:*)"
]
}
}

5
.firebaserc Normal file
View file

@ -0,0 +1,5 @@
{
"projects": {
"default": "your-firebase-project-id"
}
}

165
.gitignore vendored Normal file
View file

@ -0,0 +1,165 @@
# Environment variables
.env
.env.*
!.env.example
*.env
*.tfvars
*.tfvars.json
# SSH & Keys
*.pem
*.key
id_rsa*
*.pub
ssh_config
authorized_keys
*.p12
*.pfx
*.jks
*.keystore
*.cer
*.crt
*.der
*.gpg
*.pgp
*.asc
# Platform / Auth Secrets
firebase-auth-*.json
*service-account*.json
go-backend/firebase-service-account.json
google-services.json
GoogleService-Info.plist
*credentials.json
account_key.json
*secret*.json
*config*.json.bak
*.p8
# Supabase
.branches
.temp
supabase/.temp/
supabase/functions/**/.env
# OS
.DS_Store
Thumbs.db
desktop.ini
# IDE
.vscode/*
!.vscode/extensions.json
!.vscode/launch.json
!.vscode/tasks.json
.idea/
*.swp
*.swo
*~
*.iml
*.iws
*.ipr
# Large build artifacts and debug files
*.zip
*.tar.gz
*.tar
*.gz
*.exe
*.bin
*.db
*.sqlite
*.sqlite3
*.iso
# HAR files & Logs
*.har
localhost.har
localhost.txt
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Dependencies
node_modules/
.pnp
.pnp.js
.yarn/cache/
.yarn/unplugged/
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Testing
coverage/
.nyc_output
.shippable
# Build
dist/
build/
out/
bin/
obj/
release/
debug/
# Flutter specific
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
/sojorn_app/build/
*.g.dart
*.freezed.dart
# Service account credentials
.env_files/
# Project Specific Exclusions
logo.ai
sojorn_app/analysis_results_final.txt
go-backend/.env
go-backend/bin/
go-backend/sojorn-api*
go-backend/*linux
go-backend/seeder*
go-backend/migration*
go-backend/verify*
go-backend/migrate*
go-backend/fixdb*
go-backend/api.exe
go-backend/scripts/run_migration.go
temp_server.env
*.txt.bak
# Non-public staging area (kept local only)
_private/
# Miscellaneous Security
*.history
*.bash_history
*.zsh_history
*.mysql_history
*.psql_history
*.sqlite_history
.netrc
.shittiest_secrets
.vault
*.kdb
*.kdbx
*.sops
.node_repl_history
.python_history
.bash_profile
.bashrc
.zshrc
.profile
sojorn_docs/SOJORN_ARCHITECTURE.md

5
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"recommendations": [
"rooveterinaryinc.roo-cline"
]
}

55
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,55 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "sojorn_app",
"cwd": "sojorn_app",
"request": "launch",
"type": "dart",
"args": [
"--dart-define=SUPABASE_URL=https://zwkihedetedlatyvplyz.supabase.co",
"--dart-define=SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inp3a2loZWRldGVkbHl6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njc2Nzk3OTUsImV4cCI6MjA4MzI1NTc5NX0.7YyYOABjm7cpKa1DiefkI9bH8r6SICJ89nDK9sgUa0M",
"--dart-define=API_BASE_URL=https://zwkihedetedlatyvplyz.supabase.co/functions/v1",
"--dart-define=SUPABASE_PUBLISHABLE_KEY=sb_publishable_EnvwO9qKrsm35YmfZ7ghWA_liH7fcYX",
"--dart-define=SUPABASE_SECRET_KEY=sb_secret_s3Nyt27pcqk1P80974sxOw_cqQSexYU",
"--dart-define=SUPABASE_JWT_KID=b66bc58d-34b8-481d-9eb9-8f6e3b90714e",
"--dart-define=SUPABASE_JWKS_URI=https://zwkihedetedlatyvplyz.supabase.co/auth/v1/.well-known/jwks.json"
]
},
{
"name": "sojorn_app (profile mode)",
"cwd": "sojorn_app",
"request": "launch",
"type": "dart",
"flutterMode": "profile",
"args": [
"--dart-define=SUPABASE_URL=https://zwkihedetedlatyvplyz.supabase.co",
"--dart-define=SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inp3a2loZWRldGVkbHl6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njc2Nzk3OTUsImV4cCI6MjA4MzI1NTc5NX0.7YyYOABjm7cpKa1DiefkI9bH8r6SICJ89nDK9sgUa0M",
"--dart-define=API_BASE_URL=https://zwkihedetedlatyvplyz.supabase.co/functions/v1",
"--dart-define=SUPABASE_PUBLISHABLE_KEY=sb_publishable_EnvwO9qKrsm35YmfZ7ghWA_liH7fcYX",
"--dart-define=SUPABASE_SECRET_KEY=sb_secret_s3Nyt27pcqk1P80974sxOw_cqQSexYU",
"--dart-define=SUPABASE_JWT_KID=b66bc58d-34b8-481d-9eb9-8f6e3b90714e",
"--dart-define=SUPABASE_JWKS_URI=https://zwkihedetedlatyvplyz.supabase.co/auth/v1/.well-known/jwks.json"
]
},
{
"name": "sojorn_app (release mode)",
"cwd": "sojorn_app",
"request": "launch",
"type": "dart",
"flutterMode": "release",
"args": [
"--dart-define=SUPABASE_URL=https://zwkihedetedlatyvplyz.supabase.co",
"--dart-define=SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inp3a2loZWRldGVkbHl6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njc2Nzk3OTUsImV4cCI6MjA4MzI1NTc5NX0.7YyYOABjm7cpKa1DiefkI9bH8r6SICJ89nDK9sgUa0M",
"--dart-define=API_BASE_URL=https://zwkihedetedlatyvplyz.supabase.co/functions/v1",
"--dart-define=SUPABASE_PUBLISHABLE_KEY=sb_publishable_EnvwO9qKrsm35YmfZ7ghWA_liH7fcYX",
"--dart-define=SUPABASE_SECRET_KEY=sb_secret_s3Nyt27pcqk1P80974sxOw_cqQSexYU",
"--dart-define=SUPABASE_JWT_KID=b66bc58d-34b8-481d-9eb9-8f6e3b90714e",
"--dart-define=SUPABASE_JWKS_URI=https://zwkihedetedlatyvplyz.supabase.co/auth/v1/.well-known/jwks.json"
]
}
]
}

3
Caddyfile Normal file
View file

@ -0,0 +1,3 @@
api.sojorn.net {
reverse_proxy localhost:8080
}

52
LICENSE.md Normal file
View file

@ -0,0 +1,52 @@
# Business Source License 1.1
**License text copyright © 2017 MariaDB Corporation Ab, All Rights Reserved.**
**"Business Source License" is a trademark of MariaDB Corporation Ab.**
---
## Parameters
| Parameter | Value |
|---|---|
| **Licensor** | MPLS LLC |
| **Licensed Work** | Sojorn — all Go backend, Flutter application, AI gateway, and proprietary algorithms contained in this repository. Copyright © 20252026 MPLS LLC. |
| **Additional Use Grant** | You may use the Licensed Work for personal and non-commercial purposes without restriction. You may use the Licensed Work for commercial purposes if your entity has less than $5,000,000 (USD) in annual gross revenue. Any commercial entity exceeding this revenue threshold, or any entity providing a competing social network service, must contact MPLS LLC for a separate commercial license agreement. |
| **Change Date** | February 12, 2029 |
| **Change License** | GNU General Public License v3.0 or later |
---
## Notice
The Business Source License (this document, or the "License") is not an Open Source license. However, the Licensed Work will eventually be made available under an Open Source license, as stated in this License.
## License Text
The Licensor hereby grants you the right to copy, modify, create derivative works, redistribute, and make non-production use of the Licensed Work. The Licensor may make an Additional Use Grant, above, permitting limited production use.
Effective on the Change Date, or the fourth anniversary of the first publicly available distribution of a specific version of the Licensed Work under this License, whichever comes first, the Licensor hereby grants you rights under the terms of the Change License, and the rights granted in the paragraph above terminate.
If your use of the Licensed Work does not comply with the requirements currently in effect as described in this License, you must purchase a commercial license from the Licensor, its affiliated entities, or authorized resellers, or you must refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and derivative works of the Licensed Work, are subject to this License. This License applies separately for each version of the Licensed Work and the Change Date may vary for each version of the Licensed Work released by Licensor.
You must conspicuously display this License on each original or modified copy of the Licensed Work. If you receive the Licensed Work in original or modified form from a third party, the terms and conditions set forth in this License apply to your use of that work.
Any use of the Licensed Work in violation of this License will automatically terminate your rights under this License for the current and all other versions of the Licensed Work.
This License does not grant you any right in any trademark or logo of Licensor or its affiliates (provided that you may use a trademark or logo of Licensor as expressly required by this License).
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE.
---
## Why This License?
We believe in transparency. We share our source code so that our users, security researchers, and the broader community can verify that we honor our privacy commitments. We chose the Business Source License because it protects the labor of a small, independent team while ensuring this code eventually becomes fully open source.
We call this **Right Livelihood for Creators**: we share our work for your safety, but we protect it so we can remain independent and never need to sell your data to survive.
If you are an individual, a researcher, a nonprofit, or a small business — welcome. Use this code freely. If you are a large commercial entity, we simply ask that you contact us so we can build a fair relationship.
**Contact:** [legal@mp.ls](mailto:legal@mp.ls)

144
PRIVACY.md Normal file
View file

@ -0,0 +1,144 @@
# Sojorn — Privacy & Data Sovereignty
**Effective Date:** February 12, 2026
**Last Updated:** February 12, 2026
**Operator:** MPLS LLC
---
## Our Philosophy: Privacy as a Sanctuary
Profiting from surveillance is strictly against our principles. We reject the "attention economy" model entirely.
Most social platforms treat your data as their product. They harvest your posts, your photos, your location, your relationships, and your attention — then sell access to the highest bidder. We built Sojorn to prove that a social network can exist without any of that.
**Sojorn is a walled garden where your data is not a commodity.** We are groundskeepers of this space — not owners of what grows in it.
---
## 1. Data Sovereignty
We do not sell your data. We do not license your data. We do not provide your data to third-party analytics, advertising, or data brokerage firms. Your content is not indexed on public search engines. Sojorn is a private community designed to protect your posts and identity from the extractivist economy.
## 2. What We Collect
We collect only what is technically necessary to operate the Service:
| Data | Purpose | Retention |
|---|---|---|
| **Email address** | Authentication, critical account notifications | Until account deletion |
| **Birth month & year** | Age verification (16+ requirement) | Until account deletion |
| **Display name & handle** | Profile identity within the network | Until account deletion |
| **Content you create** | Posts, comments, images, video — displayed to your chosen audience | Until you delete it |
| **Approximate location** (Beacons only) | Community safety incident reporting | Ephemeral — not stored permanently |
| **Device push tokens** | Delivering notifications you have opted into | Until account deletion or token refresh |
We do **not** collect:
- Precise GPS location outside of Beacons
- Contact lists or phone books
- Browsing history outside of Sojorn
- Biometric data
- Financial information
## 3. Third-Party Services
| Service | Purpose | Data Shared |
|---|---|---|
| **Firebase** | Authentication, push notifications | Email, device token |
| **Cloudflare R2** | Media file storage (images, video) | Uploaded media files |
| **SendPulse** | Newsletter delivery (opt-in only) | Email address |
| **OpenAI / Google Vision** | Content moderation (hate speech, violence detection) | Text snippets and image URLs of public posts only |
We do **not** use third-party tracking pixels, cross-site cookies, behavioral analytics, or advertising SDKs.
### AI Moderation Disclosure
Public posts may be analyzed by AI moderation systems to detect policy violations (hate speech, violence, spam, NSFW content). This analysis:
- Is performed only on content you post publicly or within groups.
- Does **not** apply to end-to-end encrypted messages or capsule content.
- Does **not** train AI models on your content — we use pre-trained safety classifiers only.
- Is subject to human review before permanent moderation action.
- Produces an audit trail visible to administrators for accountability.
## 4. Zero-Knowledge Encryption
Private messages and encrypted capsule content are protected by end-to-end encryption (E2EE) using keys generated on your device. Your encryption keys are wrapped with a passphrase only you know and stored as an opaque encrypted blob on our servers. **We cannot decrypt your private content.** We cannot comply with requests to produce content we cannot read.
## 5. Your Right to Vanish
You have the absolute right to delete your account and all associated data at any time.
When you delete content or your account, we perform **hard deletes**:
- Database records are permanently removed (not soft-deleted).
- Media files (images, video) are permanently removed from storage buckets.
- Encryption key backups are permanently removed.
- We do not retain shadow copies, hidden archives, or behavioral profiles.
When you leave, you leave.
## 6. Anti-Extraction Commitment
MPLS LLC will never:
- Use your content to train artificial intelligence or machine learning models.
- Sell, license, or share your content with data brokers or advertisers.
- Build advertising or behavioral profiles from your activity.
- Provide "data partnerships" or "audience insights" products derived from your content.
## 7. Right to Livelihood
If MPLS LLC ever wishes to feature your content in promotional materials outside of the Sojorn app interface, we must contact you directly, offer financial compensation, and receive your explicit written consent. See Section 4.5 of our [Terms of Service](https://sojorn.net/terms) for full details.
## 8. Anti-Scraping
We actively defend against unauthorized commercial harvesting of user content through rate limiting, authentication requirements, and automated abuse detection. Unauthorized scraping of Sojorn content is a violation of these Terms and may be pursued under the Computer Fraud and Abuse Act (CFAA).
## 9. Law Enforcement
We will comply with valid legal process (court orders, subpoenas) as required by law. However:
- We will notify affected users unless legally prohibited from doing so.
- We cannot produce end-to-end encrypted content (we do not have the keys).
- We will challenge overbroad or legally deficient requests.
- We will publish a transparency report annually documenting any government data requests received.
## 10. Children's Privacy
Sojorn is not intended for users under 16. We do not knowingly collect data from children. If we discover that a user is under 16, we will delete their account and all associated data.
## 11. International Users
Sojorn is operated by MPLS LLC from the United States. If you are accessing the Service from the European Union, your data is processed in the United States. We apply the same privacy protections to all users regardless of jurisdiction.
## 12. Changes to This Policy
We will notify registered users via email and in-app notification of any material changes to this Privacy Policy at least 30 days before they take effect.
## 13. Contact
For privacy concerns: [privacy@sojorn.net](mailto:privacy@sojorn.net)
For legal inquiries: [legal@mp.ls](mailto:legal@mp.ls)
---
## Why We Chose This Model
### Right Livelihood for Creators
Our source code is published under the [Business Source License 1.1](./LICENSE.md). We share our code so that users, security researchers, and the public can verify that we honor every commitment in this document. We chose this license because it protects the labor of a small, independent team while ensuring the code eventually becomes fully open source (GPLv3 after February 12, 2029).
We call this **Right Livelihood for Creators** — we share our work for your safety, but we protect it so we can remain independent and never need to monetize your attention or your data.
### Privacy as a Sanctuary
Every technical decision we make is measured against a simple question: *Does this protect or erode the sanctuary?*
- We chose E2EE for private messages — because a sanctuary has walls.
- We chose hard deletes — because a sanctuary does not hoard what you discard.
- We chose AI moderation with human review — because a sanctuary has guardians, not surveillance cameras.
- We chose no advertising SDK — because a sanctuary is not a billboard.
**MPLS LLC — Groundskeepers, not owners.**

13
SVG/Artboard 4.svg Normal file
View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="LEAF" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>
.cls-1 {
fill: #fff;
stroke-width: 0px;
}
</style>
</defs>
<path class="cls-1" d="M15.46,13.32c.09-.32.17-.65.26-.97.01-.05.04-.18.04-.18,0,0,.1-.04.15-.07.9-.5,1.9-.78,2.91-.89s2.06-.13,3.04.07c.65.13,1.28.38,1.89.64,0,0-.44,1.04-.76,1.54-.5.76-1,1.5-1.63,2.15-1.41,1.44-3.29,2.47-5.25,2.58-1.32.08-2.65-.25-3.77-1.01.04-.03.57-.47.59-.45.77.6,1.78.71,2.73.64,1.07-.08,2.12-.37,3.1-.84,1.31-.63,2.44-1.68,3.26-2.94.25-.4.49-.81.73-1.22.02-.04.1-.14.1-.14,0,0-.1-.02-.16-.03-.48-.1-.95-.22-1.43-.29-1.97-.29-3.92.04-5.57,1.27-.07.05-.14.1-.21.16Z"/>
<path class="cls-1" d="M8.95,12.78s-1.01-.55-1.01-.55c-.07-.06,0-.64,0-.75.01-.27.03-.53.06-.8.06-.52.16-1.04.31-1.54.31-1.01.84-1.95,1.43-2.8.46-.66.98-1.28,1.52-1.87.09-.1,1.02-.98,1.06-.94,1.23,1.35,2.54,2.91,3.1,4.73.48,1.56.56,3.52-.18,5.02-.19.47-.4.94-.69,1.35-.52.73-1.09,1.75-3.09,3-.64.32-1.31.52-2.01.58-1.02.08-2.01.09-3.01-.16-1.73-.43-3.27-1.53-4.44-2.95-.76-.92-1.36-1.99-1.75-3.15,2.06-.9,4.43-.84,6.53-.06s3.95,2.25,5.42,4.04l-.67.48c-1.49-1.56-3.05-3.16-5.01-3.91-1.67-.64-3.53-.6-5.24-.07.89,1.83,2.3,3.39,4.05,4.25,1.73.86,3.9,1.04,5.67.19.32-.15.62-.34.9-.55.06-.04.24-.18.46-.39.26-.25.51-.53.87-1.01.33-.44.6-.83.88-1.29.97-1.62,1.19-3.54.59-5.35-.24-.73-.6-1.41-1.02-2.04-.2-.3-.42-.62-.67-.88-.05-.05-.68-.72-.69-.71-.42.37-.85.76-1.2,1.2-.66.83-1.16,1.81-1.56,2.81-.52,1.33-.79,2.69-.62,4.12Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

101
TERMS.md Normal file
View file

@ -0,0 +1,101 @@
# Sojorn — Terms of Service
**Effective Date:** February 12, 2026
**Last Updated:** February 12, 2026
**Operator:** MPLS LLC
---
## 1. The Agreement
By accessing Sojorn ("the Service"), you acknowledge that you are entering a space dedicated to respect, safety, and progressive action. These Terms of Service ("Terms") constitute a binding agreement between you and MPLS LLC ("we," "us," "our"). We prioritize the safety of our community above all else.
## 2. Zero Tolerance Policy
We do not tolerate intolerance. Hate speech, racism, sexism, homophobia, transphobia, ableism, and fascist ideologies are strictly prohibited. Violations will result in immediate and permanent account suspension.
## 3. No Misinformation
We reject the spread of verifiable falsehoods, conspiracy theories, and coordinated disinformation campaigns. Posting content designed to deceive or manipulate will result in account termination.
## 4. Content Ownership and Sanctuary
This section replaces the broad content licenses found in conventional social media Terms of Service. We believe your work belongs to you — always.
### 4.1 Ownership
**You retain 100% copyright and all intellectual property rights to every piece of content you create on Sojorn.** We claim no ownership over your words, images, audio, video, or any other creative work.
### 4.2 Limited Technical License
By posting content, you grant MPLS LLC a **non-exclusive, royalty-free, worldwide license solely for the technical purpose of hosting, displaying, and transmitting your content** to the audience you designate within Sojorn. This license exists only so that our servers can store and deliver your content as the software requires.
### 4.3 Immediate Revocation
This technical license **expires immediately and irrevocably upon deletion** of the content by you. When you delete a post, comment, image, or any other content, we will remove it from our active systems and storage buckets. We do not retain shadow copies, hidden archives, or "soft-deleted" records of your content.
### 4.4 Anti-Extraction Covenant
MPLS LLC **will never**:
- Use your content to train artificial intelligence or machine learning models.
- Sell, license, or provide your content to third-party data brokers, advertisers, or analytics firms.
- Mine your content for advertising profiles or behavioral targeting.
- Index your content on public search engines without your explicit opt-in.
### 4.5 Right to Livelihood
If MPLS LLC ever wishes to use your content for promotional purposes outside the Sojorn application interface — including but not limited to marketing materials, press releases, social media promotion, or investor presentations — we must:
1. **Contact you directly** with a clear written description of the intended use.
2. **Offer financial compensation** for that use.
3. **Receive your explicit, written opt-in consent** before any such use.
No blanket consent is granted by agreeing to these Terms. Each promotional use requires a separate agreement.
## 5. Your Right to Vanish
You have the absolute right to delete your account and all associated data at any time. When you leave, you leave. We perform hard deletes — your profile, posts, comments, media files, and metadata are permanently removed from our systems.
## 6. End-to-End Encryption
Private messages and encrypted capsule content are protected by end-to-end encryption (E2EE) using keys generated on your device. MPLS LLC has no ability to decrypt, read, or access this content. We cannot comply with requests to produce content we cannot read.
## 7. AI Moderation Transparency
We use artificial intelligence to assist with content moderation (detecting hate speech, violence, spam, and other policy violations). This AI moderation:
- Operates only on content you post publicly or within groups.
- Does not analyze private encrypted messages (we cannot).
- Is subject to human review — AI flags are reviewed by human moderators before permanent action is taken.
- Provides a full audit trail visible to administrators for accountability.
You may appeal any AI moderation decision through our in-app appeal process.
## 8. Community Safety Beacons
Sojorn includes a community safety feature ("Beacons") that allows users to report real-world safety incidents with location data. By using this feature, you consent to sharing approximate location data with other Sojorn users in your vicinity. Location data is not stored permanently and is not sold to third parties.
## 9. Age Requirement
You must be at least 16 years of age to use Sojorn. We collect birth month and year during registration solely to enforce this requirement.
## 10. Liability
MPLS LLC provides this Service "as is." We are not liable for interactions that occur between users, though we commit to active moderation to maintain community safety. We are not liable for the accuracy of community safety Beacons posted by users.
## 11. Governing Law
These Terms are governed by the laws of the State of Minnesota, United States.
## 12. Changes to These Terms
We will notify registered users via email and in-app notification of any material changes to these Terms at least 30 days before they take effect.
## 13. Contact
For questions about these Terms: [legal@mp.ls](mailto:legal@mp.ls)
---
*MPLS LLC — Groundskeepers, not owners.*

View file

@ -0,0 +1,103 @@
-- ============================================================================
-- APPLY E2EE CHAT MIGRATION MANUALLY
-- ============================================================================
-- Run this script in your Supabase SQL Editor to apply the E2EE chat migration
-- ============================================================================
-- ============================================================================
-- 1. Update profiles table to store identity key and registration ID
-- ============================================================================
-- Add Signal Protocol identity key and registration ID to profiles
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS identity_key TEXT,
ADD COLUMN IF NOT EXISTS registration_id INTEGER;
-- ============================================================================
-- 2. Create separate one_time_prekeys table
-- ============================================================================
-- Separate table for one-time pre-keys (consumed on use)
CREATE TABLE IF NOT EXISTS one_time_prekeys (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
key_id INTEGER NOT NULL,
public_key TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
-- Ensure unique key_id per user
UNIQUE(user_id, key_id)
);
-- Index for efficient key consumption
CREATE INDEX IF NOT EXISTS idx_one_time_prekeys_user_id ON one_time_prekeys(user_id);
-- ============================================================================
-- 3. Update signal_keys table structure
-- ============================================================================
-- Remove one_time_prekeys from signal_keys (now separate table)
ALTER TABLE signal_keys
DROP COLUMN IF EXISTS one_time_prekeys;
-- Add registration_id to signal_keys if not already present
ALTER TABLE signal_keys
ADD COLUMN IF NOT EXISTS registration_id INTEGER;
-- ============================================================================
-- 4. Update consume_one_time_prekey function
-- ============================================================================
-- Drop existing function if it exists (different return type)
DROP FUNCTION IF EXISTS consume_one_time_prekey(UUID);
-- Create the new function to work with the separate table
CREATE FUNCTION consume_one_time_prekey(target_user_id UUID)
RETURNS TABLE(key_id INTEGER, public_key TEXT) AS $$
DECLARE
selected_key_id INTEGER;
selected_public_key TEXT;
BEGIN
-- First, find the oldest key
SELECT otpk.key_id, otpk.public_key
INTO selected_key_id, selected_public_key
FROM one_time_prekeys otpk
WHERE otpk.user_id = target_user_id
ORDER BY otpk.created_at ASC
LIMIT 1;
-- If we found a key, delete it and return it
IF selected_key_id IS NOT NULL THEN
DELETE FROM one_time_prekeys
WHERE user_id = target_user_id AND key_id = selected_key_id;
RETURN QUERY SELECT selected_key_id, selected_public_key;
END IF;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================
-- 5. Update RLS policies for one_time_prekeys
-- ============================================================================
-- Enable RLS
ALTER TABLE one_time_prekeys ENABLE ROW LEVEL SECURITY;
-- Users can read their own pre-keys (for management)
CREATE POLICY one_time_prekeys_select_own ON one_time_prekeys
FOR SELECT USING (auth.uid() = user_id);
-- Users can insert their own pre-keys
CREATE POLICY one_time_prekeys_insert_own ON one_time_prekeys
FOR INSERT WITH CHECK (auth.uid() = user_id);
-- Users can delete their own pre-keys (when consumed)
CREATE POLICY one_time_prekeys_delete_own ON one_time_prekeys
FOR DELETE USING (auth.uid() = user_id);
-- ============================================================================
-- SUCCESS MESSAGE
-- ============================================================================
-- If you see this message, the migration completed successfully!
SELECT 'E2EE Chat Migration Applied Successfully!' as status;

View file

@ -0,0 +1,2 @@
[functions.cleanup-expired-content]
verify_jwt = false

View file

@ -0,0 +1,20 @@
{
"deno.enable": true,
"deno.lint": true,
"deno.unstable": false,
"javascript.preferences.quoteStyle": "single",
"typescript.preferences.quoteStyle": "single",
"search.exclude": {
"**/node_modules": true,
"**/dist": true
},
"typescript.validate.enable": false,
"javascript.validate.enable": false,
"files.associations": {
"*.ts": "typescript"
},
"editor.defaultFormatter": "denoland.vscode-deno",
"[typescript]": {
"editor.defaultFormatter": "denoland.vscode-deno"
}
}

View file

@ -0,0 +1,193 @@
/**
* Harmony Score Calculation
*
* Design intent:
* - Influence adapts; people are not judged.
* - Guidance replaces punishment.
* - Fit emerges naturally.
*
* Philosophy:
* - Score is private, decays over time, and is reversible.
* - Never bans or removes beliefs.
* - Shapes distribution width, not access.
*
* Inputs:
* - Blocks received (pattern-based, not single incidents)
* - Trusted reports (from high-harmony users)
* - Category friction (posting to sensitive categories with low CIS)
* - Posting cadence (erratic spikes vs steady participation)
* - Rewrite prompts triggered (content rejected for tone)
* - False reports made (reports that were dismissed)
*
* Effects:
* - Shapes distribution width (reach)
* - Adds gentle posting friction if low
* - Limits Trending eligibility
*/
export interface HarmonyInputs {
user_id: string;
blocks_received_7d: number;
blocks_received_30d: number;
trusted_reports_against: number;
total_reports_against: number;
posts_rejected_7d: number; // rewrite prompts triggered
posts_created_7d: number;
false_reports_filed: number; // reports dismissed after review
validated_reports_filed: number; // reports confirmed after review
days_since_signup: number;
current_harmony_score: number;
current_tier: string;
}
export interface HarmonyAdjustment {
new_score: number;
delta: number;
reason: string;
new_tier: string;
}
/**
* Calculate harmony score adjustment based on recent behavior
*/
export function calculateHarmonyAdjustment(inputs: HarmonyInputs): HarmonyAdjustment {
let delta = 0;
const reasons: string[] = [];
// 1. Blocks received (pattern-based)
// Single block = minor signal. Pattern of blocks = strong negative signal.
if (inputs.blocks_received_7d >= 3) {
delta -= 10;
reasons.push('Multiple blocks received recently');
} else if (inputs.blocks_received_30d >= 5) {
delta -= 5;
reasons.push('Pattern of blocks over time');
}
// 2. Trusted reports
// Reports from high-harmony users are strong negative signals
if (inputs.trusted_reports_against >= 2) {
delta -= 8;
reasons.push('Multiple reports from trusted users');
} else if (inputs.trusted_reports_against === 1) {
delta -= 3;
reasons.push('Report from trusted user');
}
// 3. Content rejection rate (rewrite prompts)
// High rejection rate indicates persistent tone issues
const rejectionRate =
inputs.posts_created_7d > 0 ? inputs.posts_rejected_7d / inputs.posts_created_7d : 0;
if (rejectionRate > 0.3) {
delta -= 6;
reasons.push('High content rejection rate');
} else if (rejectionRate > 0.1) {
delta -= 2;
reasons.push('Some content rejected for tone');
}
// 4. False reports filed
// Filing false reports is harmful behavior
if (inputs.false_reports_filed >= 3) {
delta -= 7;
reasons.push('Multiple false reports filed');
} else if (inputs.false_reports_filed >= 1) {
delta -= 3;
reasons.push('False report filed');
}
// 5. Positive signals: Validated reports
// Accurate reporting helps the community
if (inputs.validated_reports_filed >= 3) {
delta += 5;
reasons.push('Helpful reporting behavior');
} else if (inputs.validated_reports_filed >= 1) {
delta += 2;
reasons.push('Validated report filed');
}
// 6. Time-based trust growth
// Steady participation without issues slowly builds trust
if (inputs.days_since_signup > 90 && delta >= 0) {
delta += 2;
reasons.push('Sustained positive participation');
} else if (inputs.days_since_signup > 30 && delta >= 0) {
delta += 1;
reasons.push('Consistent participation');
}
// 7. Natural decay toward equilibrium (50)
// Scores gradually drift back toward 50 over time
// This ensures old negative signals fade
if (inputs.current_harmony_score < 45) {
delta += 1;
reasons.push('Natural recovery over time');
} else if (inputs.current_harmony_score > 60) {
delta -= 1;
reasons.push('Natural equilibrium adjustment');
}
// 8. Calculate new score with bounds [0, 100]
const new_score = Math.max(0, Math.min(100, inputs.current_harmony_score + delta));
// 9. Determine tier based on new score
let new_tier: string;
if (new_score >= 75) {
new_tier = 'established';
} else if (new_score >= 50) {
new_tier = 'trusted';
} else if (new_score >= 25) {
new_tier = 'new';
} else {
new_tier = 'restricted';
}
return {
new_score,
delta,
reason: reasons.join('; ') || 'No significant changes',
new_tier,
};
}
/**
* Get user-facing explanation of harmony score (without revealing the number)
*/
export function getHarmonyExplanation(tier: string, score: number): string {
if (tier === 'restricted') {
return 'Your posts currently have limited reach. This happens when content patterns trigger community concerns. Your reach will naturally restore over time with calm participation.';
}
if (tier === 'new') {
return 'Your posts reach a modest audience while you build trust. Steady participation and helpful contributions will gradually expand your reach.';
}
if (tier === 'trusted') {
return 'Your posts reach a good audience. You have shown consistent, calm participation.';
}
if (tier === 'established') {
return 'Your posts reach a wide audience. You have built strong trust through sustained positive contributions.';
}
return 'Your reach is determined by your participation patterns and community response.';
}
/**
* Determine reach multiplier for feed algorithms
*/
export function getReachMultiplier(tier: string, score: number): number {
const baseMultiplier: Record<string, number> = {
restricted: 0.2,
new: 0.6,
trusted: 1.0,
established: 1.4,
};
// Fine-tune based on score within tier
const tierBase = baseMultiplier[tier] || 1.0;
const scoreAdjustment = (score - 50) / 200; // -0.25 to +0.25
return Math.max(0.1, tierBase + scoreAdjustment);
}

View file

@ -0,0 +1,108 @@
import { AwsClient } from 'https://esm.sh/aws4fetch@1.0.17'
const CUSTOM_MEDIA_DOMAIN = (Deno.env.get("CUSTOM_MEDIA_DOMAIN") ?? "https://img.sojorn.net").trim();
const CUSTOM_VIDEO_DOMAIN = (Deno.env.get("CUSTOM_VIDEO_DOMAIN") ?? "https://quips.sojorn.net").trim();
const DEFAULT_BUCKET_NAME = "sojorn-media";
const RESOLVED_BUCKET = (Deno.env.get("R2_BUCKET_NAME") ?? DEFAULT_BUCKET_NAME).trim();
function normalizeKey(key: string): string {
let normalized = key.replace(/^\/+/, "");
if (RESOLVED_BUCKET && normalized.startsWith(`${RESOLVED_BUCKET}/`)) {
normalized = normalized.slice(RESOLVED_BUCKET.length + 1);
}
return normalized;
}
function extractObjectKey(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("Missing file key");
}
try {
const url = new URL(trimmed);
const key = decodeURIComponent(url.pathname);
return normalizeKey(key);
} catch {
return normalizeKey(trimmed);
}
}
export function transformLegacyMediaUrl(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
try {
const url = new URL(trimmed);
// Handle legacy media.sojorn.net URLs
if (url.hostname === 'media.sojorn.net') {
const key = decodeURIComponent(url.pathname);
return key;
}
return null;
} catch {
return null;
}
}
// Deprecated: no-op signer retained for compatibility
export async function signR2Url(fileKey: string, expiresIn: number = 3600): Promise<string> {
return await trySignR2Url(fileKey, undefined, expiresIn) ?? fileKey;
}
export async function trySignR2Url(fileKey: string, bucket?: string, expiresIn: number = 3600): Promise<string | null> {
try {
const key = normalizeKey(extractObjectKey(fileKey));
// Check if we have credentials to sign. If not, fallback to public URL.
const ACCOUNT_ID = Deno.env.get('R2_ACCOUNT_ID');
const ACCESS_KEY = Deno.env.get('R2_ACCESS_KEY');
const SECRET_KEY = Deno.env.get('R2_SECRET_KEY');
const isVideo = key.toLowerCase().endsWith('.mp4') ||
key.toLowerCase().endsWith('.mov') ||
key.toLowerCase().endsWith('.webm') ||
bucket === 'sojorn-videos';
if (!ACCOUNT_ID || !ACCESS_KEY || !SECRET_KEY) {
console.warn("Missing R2 credentials for signing. Falling back to public domain.");
const domain = isVideo ? CUSTOM_VIDEO_DOMAIN : CUSTOM_MEDIA_DOMAIN;
if (domain && domain.startsWith("http")) {
return `${domain.replace(/\/+$/, "")}/${key}`;
}
return fileKey;
}
const r2 = new AwsClient({
accessKeyId: ACCESS_KEY,
secretAccessKey: SECRET_KEY,
region: 'auto',
service: 's3',
});
const targetBucket = bucket || (isVideo ? 'sojorn-videos' : 'sojorn-media');
// We sign against the actual R2 endpoint to ensure auth works,
// but the SignedMediaImage can handle redirect/proxying if needed.
const url = new URL(`https://${ACCOUNT_ID}.r2.cloudflarestorage.com/${targetBucket}/${key}`);
// Add expiration
url.searchParams.set('X-Amz-Expires', expiresIn.toString());
const signedRequest = await r2.sign(url, {
method: "GET",
aws: { signQuery: true, allHeaders: false },
});
return signedRequest.url;
} catch (error) {
console.error("R2 signing failed", {
fileKey,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}

View file

@ -0,0 +1,133 @@
/**
* Ranking Algorithm for sojorn Feed
*
* Design intent:
* - Attention moves slowly.
* - Nothing competes for dominance.
* - Clean content is protected from suppression.
*
* Principles:
* - Saves > likes (intentional curation over quick reaction)
* - Steady appreciation over time > viral spikes
* - Low block rate = content is not harmful
* - Low trusted report rate = content is genuinely clean
* - Ignore comment count (we don't reward arguments)
* - Ignore report spikes on high-CIS posts (brigading protection)
*/
export interface PostForRanking {
id: string;
created_at: string;
cis_score: number;
tone_label: string;
save_count: number;
like_count: number;
view_count: number;
author_harmony_score: number;
author_tier: string;
blocks_received_24h: number;
trusted_reports: number;
total_reports: number;
}
/**
* Calculate calm velocity score
* Measures steady appreciation rather than viral spikes
*/
function calculateCalmVelocity(post: PostForRanking): number {
const ageInHours = (Date.now() - new Date(post.created_at).getTime()) / (1000 * 60 * 60);
if (ageInHours === 0 || post.view_count === 0) return 0;
// Saves are weighted 3x more than likes
const engagementScore = post.save_count * 3 + post.like_count;
// Engagement rate (relative to views)
const engagementRate = engagementScore / Math.max(post.view_count, 1);
// Calm velocity = steady engagement over time (not spiky)
// Using logarithmic scaling to prevent runaway viral effects
const velocity = Math.log1p(engagementRate * 100) / Math.log1p(ageInHours + 1);
return velocity;
}
/**
* Calculate safety score
* Lower score = content has triggered negative signals
*/
function calculateSafetyScore(post: PostForRanking): number {
let score = 1.0;
// Penalize if blocks received in last 24h
if (post.blocks_received_24h > 0) {
score -= post.blocks_received_24h * 0.2;
}
// Penalize trusted reports heavily
if (post.trusted_reports > 0) {
score -= post.trusted_reports * 0.3;
}
// Ignore report spikes if CIS is high (brigading protection)
if (post.cis_score < 0.7 && post.total_reports > 2) {
score -= 0.15;
}
return Math.max(score, 0);
}
/**
* Calculate author influence multiplier
* Based on harmony score and tier
*/
function calculateAuthorInfluence(post: PostForRanking): number {
const harmonyMultiplier = post.author_harmony_score / 100; // 0-1 range
const tierMultiplier: Record<string, number> = {
new: 0.5,
trusted: 1.0,
established: 1.3,
restricted: 0.2,
};
return harmonyMultiplier * (tierMultiplier[post.author_tier] || 1.0);
}
/**
* Calculate final ranking score for sojorn feed
*/
export function calculateRankingScore(post: PostForRanking): number {
// Base score from content integrity
const cisBonus = post.cis_score;
// Tone eligibility (handled in feed query, but can boost here)
const toneBonus = post.tone_label === 'positive' ? 1.2 : post.tone_label === 'neutral' ? 1.0 : 0.8;
// Calm velocity (steady appreciation)
const velocity = calculateCalmVelocity(post);
// Safety (no blocks or trusted reports)
const safety = calculateSafetyScore(post);
// Author influence
const influence = calculateAuthorInfluence(post);
// Final score
const score = cisBonus * toneBonus * velocity * safety * influence;
return score;
}
/**
* Rank posts for feed
* Returns sorted array with scores attached
*/
export function rankPosts(posts: PostForRanking[]): Array<PostForRanking & { rank_score: number }> {
return posts
.map((post) => ({
...post,
rank_score: calculateRankingScore(post),
}))
.sort((a, b) => b.rank_score - a.rank_score);
}

View file

@ -0,0 +1,27 @@
/**
* Shared Supabase client configuration for Edge Functions
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
export function createSupabaseClient(authHeader: string) {
return createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{
global: {
headers: {
Authorization: authHeader,
apikey: Deno.env.get('SUPABASE_ANON_KEY') ?? '',
},
},
}
);
}
export function createServiceClient() {
return createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
);
}

View file

@ -0,0 +1,173 @@
/**
* Content Filtering with OpenAI Moderation API
*
* Philosophy:
* 1. Block slurs immediately (zero tolerance)
* 2. Send to OpenAI Moderation API for additional checking
* 3. Everything else is allowed
*/
export type ToneLabel = 'positive' | 'neutral' | 'mixed' | 'negative' | 'hostile' | 'hate';
export interface ToneAnalysis {
tone: ToneLabel;
cis: number; // content integrity score (0-1)
flags: string[]; // detected patterns
shouldReject: boolean;
rejectReason?: string;
}
// Slurs - zero tolerance (block immediately)
const SLURS = [
// Racial slurs
'nigger', 'nigga', 'negro', 'chink', 'gook', 'spic', 'wetback', 'raghead',
'sandnigger', 'coon', 'darkie', 'jap', 'zipperhead', 'mex',
// Homophobic slurs
'faggot', 'fag', 'fags', 'dyke', 'tranny', 'trannie', 'homo', 'lez', 'lesbo', 'queer',
// Other
'kike', 'spook', 'simian', 'groids', 'currymuncher', 'paki', 'cunt',
];
const OPENAI_MODERATION_URL = 'https://api.openai.com/v1/moderations';
/**
* Analyze text - first check slurs, then send to OpenAI Moderation API
*/
export async function analyzeTone(text: string): Promise<ToneAnalysis> {
const flags: string[] = [];
const lowerText = text.toLowerCase();
// Check for slurs (zero tolerance - block immediately)
const foundSlurs = SLURS.filter(slug => lowerText.includes(slug));
if (foundSlurs.length > 0) {
return {
tone: 'hate',
cis: 0.0,
flags: foundSlurs,
shouldReject: true,
rejectReason: 'This content contains slurs which are not allowed.',
};
}
// Send to OpenAI Moderation API for additional checking
const openAiKey = Deno.env.get('OPEN_AI');
console.log('OPEN_AI key exists:', !!openAiKey);
if (openAiKey) {
try {
console.log('Sending to OpenAI Moderation API, text:', text);
const response = await fetch(OPENAI_MODERATION_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${openAiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: 'omni-moderation-latest', input: text }),
});
if (response.ok) {
const data = await response.json();
const result = data.results[0];
// Check various categories (using correct OpenAI category names)
const categories = result.categories;
if (categories['hate'] || categories['hate/threatening']) {
return {
tone: 'hate',
cis: 0.0,
flags: ['openai_hate'],
shouldReject: true,
rejectReason: 'This content was flagged by moderation.',
};
}
if (categories['harassment'] || categories['harassment/threatening']) {
return {
tone: 'hostile',
cis: 0.1,
flags: ['openai_harassment'],
shouldReject: true,
rejectReason: 'This content contains harassment.',
};
}
if (categories['sexual'] || categories['sexual/minors']) {
return {
tone: 'hostile',
cis: 0.1,
flags: ['openai_sexual'],
shouldReject: true,
rejectReason: 'This content is not appropriate.',
};
}
if (categories['violence'] || categories['violence/graphic']) {
return {
tone: 'hostile',
cis: 0.1,
flags: ['openai_violence'],
shouldReject: true,
rejectReason: 'This content contains violence.',
};
}
if (categories['self-harm'] || categories['self-harm/intent'] || categories['self-harm/instructions']) {
return {
tone: 'hostile',
cis: 0.1,
flags: ['openai_self_harm'],
shouldReject: true,
rejectReason: 'This content contains self-harm references.',
};
}
}
} catch (e) {
console.error('OpenAI moderation error:', e);
// Continue with basic analysis if moderation API fails
}
}
// Determine tone based on basic sentiment
const hasProfanity = /fuck|shit|damn|ass|bitch|dick|cock|pussy|cunt|hell|bastard/i.test(text);
const isPositive = /love|thank|grateful|appreciate|happy|joy|peace|calm|beautiful|wonderful|amazing|great/i.test(text);
const isNegative = /hate|angry|furious|enraged|upset|sad|depressed|hopeless|worthless|terrible/i.test(text);
let tone: ToneLabel;
let cis: number;
if (isPositive && !isNegative) {
tone = 'positive';
cis = 0.9;
} else if (isNegative && !isPositive) {
tone = 'negative';
cis = 0.5;
flags.push('negative_tone');
} else if (hasProfanity) {
tone = 'neutral';
cis = 0.7;
flags.push('profanity');
} else {
tone = 'neutral';
cis = 0.8;
}
return { tone, cis, flags, shouldReject: false };
}
/**
* Generate user-facing feedback for rejected content
*/
export function getRewriteSuggestion(analysis: ToneAnalysis): string {
if (analysis.tone === 'hate') {
return 'Slurs are not allowed on sojorn.';
}
if (analysis.tone === 'hostile') {
return 'Sharp speech does not travel here. Consider softening your words.';
}
if (analysis.tone === 'negative') {
return 'This reads as negative. If you want it to reach others, try reframing.';
}
return 'Consider adjusting your tone for better engagement.';
}

View file

@ -0,0 +1,53 @@
/**
* Shared validation utilities
*/
export class ValidationError extends Error {
constructor(message: string, public field?: string) {
super(message);
this.name = 'ValidationError';
}
}
export function validatePostBody(body: string): void {
const trimmed = body.trim();
if (trimmed.length === 0) {
throw new ValidationError('Post cannot be empty.', 'body');
}
if (body.length > 500) {
throw new ValidationError('Post is too long (max 500 characters).', 'body');
}
}
export function validateCommentBody(body: string): void {
const trimmed = body.trim();
if (trimmed.length === 0) {
throw new ValidationError('Comment cannot be empty.', 'body');
}
if (body.length > 300) {
throw new ValidationError('Comment is too long (max 300 characters).', 'body');
}
}
export function validateReportReason(reason: string): void {
const trimmed = reason.trim();
if (trimmed.length < 10) {
throw new ValidationError('Report reason must be at least 10 characters.', 'reason');
}
if (reason.length > 500) {
throw new ValidationError('Report reason is too long (max 500 characters).', 'reason');
}
}
export function validateUUID(value: string, fieldName: string): void {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(value)) {
throw new ValidationError(`Invalid ${fieldName}.`, fieldName);
}
}

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,202 @@
/**
* POST /appreciate - Appreciate a post (boost it)
* DELETE /appreciate - Remove appreciation
*
* Design intent:
* - "Appreciate" instead of "like" - more intentional
* - Quiet appreciation matters
* - Boost-only, no downvotes
*/
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts';
import { validateUUID, ValidationError } from '../_shared/validation.ts';
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || '*';
const CORS_HEADERS = {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
'Access-Control-Allow-Methods': 'POST, DELETE',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
interface AppreciateRequest {
post_id: string;
}
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: CORS_HEADERS });
}
try {
const authHeader = req.headers.get('Authorization');
console.log('Auth header present:', !!authHeader, 'Length:', authHeader?.length ?? 0);
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
status: 401,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
});
}
const supabase = createSupabaseClient(authHeader);
const adminClient = createServiceClient();
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
console.log('Auth result - user:', user?.id ?? 'null', 'error:', authError?.message ?? 'none');
if (authError || !user) {
return new Response(JSON.stringify({
error: 'Unauthorized',
message: authError?.message ?? 'No user found'
}), {
status: 401,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
});
}
const { post_id } = (await req.json()) as AppreciateRequest;
validateUUID(post_id, 'post_id');
// Use admin client to check post existence - RLS was causing issues for some users
// The post_likes insert will still enforce that only valid posts can be liked
const { data: postRow, error: postError } = await adminClient
.from('posts')
.select('id, visibility, author_id, status')
.eq('id', post_id)
.maybeSingle();
if (postError || !postRow) {
console.error('Post lookup failed:', { post_id, error: postError?.message });
return new Response(
JSON.stringify({ error: 'Post not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
// Check if post is active (published)
// Note: posts use 'active' status for published posts
if (postRow.status !== 'active') {
return new Response(
JSON.stringify({ error: 'Post is not available' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
// For private posts, verify the user has access
if (postRow.visibility === 'private' && postRow.author_id !== user.id) {
return new Response(
JSON.stringify({ error: 'Post not accessible' }),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
// For followers-only posts, verify the user follows the author
if (postRow.visibility === 'followers' && postRow.author_id !== user.id) {
const { data: followRow } = await adminClient
.from('follows')
.select('status')
.eq('follower_id', user.id)
.eq('following_id', postRow.author_id)
.eq('status', 'accepted')
.maybeSingle();
if (!followRow) {
return new Response(
JSON.stringify({ error: 'You must follow this user to appreciate their posts' }),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
}
// Handle remove appreciation (DELETE)
if (req.method === 'DELETE') {
const { error: deleteError } = await adminClient
.from('post_likes')
.delete()
.eq('user_id', user.id)
.eq('post_id', post_id);
if (deleteError) {
console.error('Error removing appreciation:', deleteError);
return new Response(JSON.stringify({ error: 'Failed to remove appreciation' }), {
status: 500,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
});
}
// Handle appreciate (POST)
const { error: likeError } = await adminClient
.from('post_likes')
.insert({
user_id: user.id,
post_id,
});
if (likeError) {
console.error('Like error details:', JSON.stringify({
code: likeError.code,
message: likeError.message,
details: likeError.details,
hint: likeError.hint,
user_id: user.id,
post_id,
}));
// Already appreciated (duplicate key)
if (likeError.code === '23505') {
return new Response(
JSON.stringify({ error: 'You have already appreciated this post' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Post not visible (RLS blocked it)
if (likeError.message?.includes('violates row-level security')) {
return new Response(
JSON.stringify({ error: 'Post not found or not accessible', code: likeError.code }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
console.error('Error appreciating post:', likeError);
return new Response(JSON.stringify({ error: 'Failed to appreciate post', details: likeError.message }), {
status: 500,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
});
}
return new Response(
JSON.stringify({
success: true,
message: 'Appreciation noted. Quiet signals matter.',
}),
{
status: 200,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
}
);
} catch (error) {
if (error instanceof ValidationError) {
return new Response(
JSON.stringify({ error: 'Validation error', message: error.message }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
console.error('Unexpected error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,183 @@
/**
* POST /block
*
* Design intent:
* - One-tap, immediate, silent.
* - Blocking removes all visibility both ways.
* - No drama, no notification, complete separation.
*
* Flow:
* 1. Validate auth
* 2. Create block record
* 3. Remove existing follows (if any)
* 4. Log audit event
* 5. Return success
*/
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts';
import { validateUUID, ValidationError } from '../_shared/validation.ts';
interface BlockRequest {
user_id: string; // the user to block
}
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, DELETE',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
},
});
}
try {
// 1. Validate auth
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const supabase = createSupabaseClient(authHeader);
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// Handle unblock (DELETE method)
if (req.method === 'DELETE') {
const { user_id } = (await req.json()) as BlockRequest;
validateUUID(user_id, 'user_id');
const { error: deleteError } = await supabase
.from('blocks')
.delete()
.eq('blocker_id', user.id)
.eq('blocked_id', user_id);
if (deleteError) {
console.error('Error removing block:', deleteError);
return new Response(JSON.stringify({ error: 'Failed to remove block' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
const serviceClient = createServiceClient();
await serviceClient.rpc('log_audit_event', {
p_actor_id: user.id,
p_event_type: 'user_unblocked',
p_payload: { blocked_id: user_id },
});
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// 2. Parse request (POST method)
const { user_id: blocked_id } = (await req.json()) as BlockRequest;
// 3. Validate input
validateUUID(blocked_id, 'user_id');
if (blocked_id === user.id) {
return new Response(
JSON.stringify({
error: 'Invalid block',
message: 'You cannot block yourself.',
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
// 4. Create block (idempotent - duplicate key will be ignored)
const { error: blockError } = await supabase.from('blocks').insert({
blocker_id: user.id,
blocked_id,
});
if (blockError && !blockError.message.includes('duplicate')) {
console.error('Error creating block:', blockError);
return new Response(JSON.stringify({ error: 'Failed to create block' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
// 5. Remove any existing follows (both directions)
// This ensures complete separation
const { error: unfollowError1 } = await supabase
.from('follows')
.delete()
.eq('follower_id', user.id)
.eq('following_id', blocked_id);
const { error: unfollowError2 } = await supabase
.from('follows')
.delete()
.eq('follower_id', blocked_id)
.eq('following_id', user.id);
if (unfollowError1 || unfollowError2) {
console.warn('Error removing follows during block:', unfollowError1 || unfollowError2);
// Continue anyway - block is more important
}
// 6. Log audit event
const serviceClient = createServiceClient();
await serviceClient.rpc('log_audit_event', {
p_actor_id: user.id,
p_event_type: 'user_blocked',
p_payload: { blocked_id },
});
// 7. Return success
return new Response(
JSON.stringify({
success: true,
message: 'Block applied. You will no longer see each other.',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
} catch (error) {
if (error instanceof ValidationError) {
return new Response(
JSON.stringify({
error: 'Validation error',
message: error.message,
field: error.field,
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
console.error('Unexpected error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,215 @@
/**
* Harmony Score Calculation (Cron Job)
*
* This function runs periodically (e.g., daily) to recalculate harmony scores
* for all users based on their recent behavior patterns.
*
* Design intent:
* - Influence adapts automatically based on behavior.
* - Scores decay over time (old issues fade).
* - Changes are gradual, not sudden.
*
* Trigger: Scheduled via Supabase cron or external scheduler
*/
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createServiceClient } from '../_shared/supabase-client.ts';
import { calculateHarmonyAdjustment, type HarmonyInputs } from '../_shared/harmony.ts';
serve(async (req) => {
try {
// Verify this is a scheduled/cron request
const authHeader = req.headers.get('Authorization');
const cronSecret = Deno.env.get('CRON_SECRET');
if (!authHeader || authHeader !== `Bearer ${cronSecret}`) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const serviceClient = createServiceClient();
// 1. Get all users with their current trust state
const { data: users, error: usersError } = await serviceClient
.from('trust_state')
.select('user_id, harmony_score, tier, counters')
.order('user_id');
if (usersError) {
console.error('Error fetching users:', usersError);
return new Response(JSON.stringify({ error: 'Failed to fetch users' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
let updatedCount = 0;
let errorCount = 0;
// 2. Process each user
for (const user of users) {
try {
// Gather behavior metrics for this user
// Blocks received
const { count: blocks7d } = await serviceClient
.from('blocks')
.select('*', { count: 'exact', head: true })
.eq('blocked_id', user.user_id)
.gte('created_at', sevenDaysAgo);
const { count: blocks30d } = await serviceClient
.from('blocks')
.select('*', { count: 'exact', head: true })
.eq('blocked_id', user.user_id)
.gte('created_at', thirtyDaysAgo);
// Reports against this user
const { data: reportsAgainst } = await serviceClient
.from('reports')
.select('reporter_id, status')
.or('target_type.eq.post,target_type.eq.comment')
.in(
'target_id',
serviceClient
.from('posts')
.select('id')
.eq('author_id', user.user_id)
.then((r) => r.data?.map((p) => p.id) || [])
);
// Count trusted reports (from high-harmony reporters)
let trustedReportsCount = 0;
if (reportsAgainst) {
for (const report of reportsAgainst) {
const { data: reporterTrust } = await serviceClient
.from('trust_state')
.select('harmony_score')
.eq('user_id', report.reporter_id)
.single();
if (reporterTrust && reporterTrust.harmony_score >= 70) {
trustedReportsCount++;
}
}
}
// Posts rejected in last 7 days (from audit log)
const { data: rejectedPosts } = await serviceClient
.from('audit_log')
.select('id')
.eq('actor_id', user.user_id)
.eq('event_type', 'post_rejected')
.gte('created_at', sevenDaysAgo);
// Posts created in last 7 days
const { count: postsCreated7d } = await serviceClient
.from('posts')
.select('*', { count: 'exact', head: true })
.eq('author_id', user.user_id)
.gte('created_at', sevenDaysAgo);
// Reports filed by user
const { data: reportsFiled } = await serviceClient
.from('reports')
.select('id, status')
.eq('reporter_id', user.user_id);
const falseReports = reportsFiled?.filter((r) => r.status === 'dismissed').length || 0;
const validatedReports = reportsFiled?.filter((r) => r.status === 'resolved').length || 0;
// Days since signup
const { data: profile } = await serviceClient
.from('profiles')
.select('created_at')
.eq('id', user.user_id)
.single();
const daysSinceSignup = profile
? Math.floor((Date.now() - new Date(profile.created_at).getTime()) / (1000 * 60 * 60 * 24))
: 0;
// 3. Calculate harmony adjustment
const inputs: HarmonyInputs = {
user_id: user.user_id,
blocks_received_7d: blocks7d || 0,
blocks_received_30d: blocks30d || 0,
trusted_reports_against: trustedReportsCount,
total_reports_against: reportsAgainst?.length || 0,
posts_rejected_7d: rejectedPosts?.length || 0,
posts_created_7d: postsCreated7d || 0,
false_reports_filed: falseReports,
validated_reports_filed: validatedReports,
days_since_signup: daysSinceSignup,
current_harmony_score: user.harmony_score,
current_tier: user.tier,
};
const adjustment = calculateHarmonyAdjustment(inputs);
// 4. Update trust state if score changed
if (adjustment.delta !== 0) {
const { error: updateError } = await serviceClient
.from('trust_state')
.update({
harmony_score: adjustment.new_score,
tier: adjustment.new_tier,
updated_at: new Date().toISOString(),
})
.eq('user_id', user.user_id);
if (updateError) {
console.error(`Error updating trust state for ${user.user_id}:`, updateError);
errorCount++;
continue;
}
// 5. Log the adjustment
await serviceClient.rpc('log_audit_event', {
p_actor_id: null, // system action
p_event_type: 'harmony_recalculated',
p_payload: {
user_id: user.user_id,
old_score: user.harmony_score,
new_score: adjustment.new_score,
delta: adjustment.delta,
old_tier: user.tier,
new_tier: adjustment.new_tier,
reason: adjustment.reason,
},
});
updatedCount++;
}
} catch (userError) {
console.error(`Error processing user ${user.user_id}:`, userError);
errorCount++;
}
}
return new Response(
JSON.stringify({
success: true,
total_users: users.length,
updated: updatedCount,
errors: errorCount,
message: 'Harmony score recalculation complete',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
} catch (error) {
console.error('Unexpected error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,157 @@
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { S3Client, DeleteObjectCommand } from 'https://esm.sh/@aws-sdk/client-s3@3.470.0';
import { createServiceClient } from '../_shared/supabase-client.ts';
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
const corsHeaders = {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
const R2_ENDPOINT = (Deno.env.get('R2_ENDPOINT') ?? '').trim();
const R2_ACCOUNT_ID = (Deno.env.get('R2_ACCOUNT_ID') ?? '').trim();
const R2_ACCESS_KEY_ID = (Deno.env.get('R2_ACCESS_KEY_ID') ?? Deno.env.get('R2_ACCESS_KEY') ?? '').trim();
const R2_SECRET_ACCESS_KEY = (Deno.env.get('R2_SECRET_ACCESS_KEY') ?? Deno.env.get('R2_SECRET_KEY') ?? '').trim();
const R2_BUCKET_NAME = (Deno.env.get('R2_BUCKET_NAME') ?? '').trim();
const DEFAULT_BUCKET_NAME = 'sojorn-media';
const RESOLVED_ENDPOINT = R2_ENDPOINT || (R2_ACCOUNT_ID ? `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com` : '');
const RESOLVED_BUCKET = R2_BUCKET_NAME || DEFAULT_BUCKET_NAME;
const supabase = createServiceClient();
function extractObjectKey(imageUrl: string, bucketName: string): string | null {
try {
const url = new URL(imageUrl);
let key = url.pathname.replace(/^\/+/, '');
if (!key) return null;
if (bucketName && key.startsWith(`${bucketName}/`)) {
key = key.slice(bucketName.length + 1);
}
return decodeURIComponent(key);
} catch (_) {
return null;
}
}
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: { ...corsHeaders, 'Access-Control-Allow-Methods': 'POST OPTIONS' } });
}
if (req.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (!RESOLVED_ENDPOINT || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !RESOLVED_BUCKET) {
return new Response(JSON.stringify({ error: 'Missing R2 configuration' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const r2 = new S3Client({
region: 'auto',
endpoint: RESOLVED_ENDPOINT,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
},
forcePathStyle: true,
});
try {
const maxBatches = 50;
const batchSize = 100;
const maxRuntimeMs = 25000;
const startTime = Date.now();
let processedCount = 0;
let deletedCount = 0;
let skippedCount = 0;
let batches = 0;
while (batches < maxBatches && Date.now() - startTime < maxRuntimeMs) {
const { data: posts, error } = await supabase
.from('posts')
.select('id, image_url, expires_at')
.lt('expires_at', new Date().toISOString())
.order('expires_at', { ascending: true })
.limit(batchSize);
if (error) {
console.error('Error fetching expired posts:', error);
return new Response(JSON.stringify({ error: 'Failed to fetch expired posts' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const expiredPosts = posts ?? [];
if (expiredPosts.length === 0) break;
processedCount += expiredPosts.length;
batches += 1;
for (const post of expiredPosts) {
if (post.image_url) {
const key = extractObjectKey(post.image_url, RESOLVED_BUCKET);
if (!key) {
console.error('Could not parse image key:', { post_id: post.id, image_url: post.image_url });
skippedCount += 1;
continue;
}
try {
await r2.send(
new DeleteObjectCommand({
Bucket: RESOLVED_BUCKET,
Key: key,
})
);
} catch (error) {
console.error('R2 deletion failed:', { post_id: post.id, error });
skippedCount += 1;
continue;
}
}
const { error: deleteError } = await supabase
.from('posts')
.delete()
.eq('id', post.id);
if (deleteError) {
console.error('Failed to delete post row:', { post_id: post.id, error: deleteError });
skippedCount += 1;
continue;
}
deletedCount += 1;
}
}
return new Response(
JSON.stringify({
processed: processedCount,
deleted: deletedCount,
skipped: skippedCount,
batches,
}),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
} catch (error) {
console.error('Unexpected cleanup error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,98 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
interface ConsumeOneTimePrekeyRequest {
target_user_id: string
}
serve(async (req) => {
// Handle CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
// Create a Supabase client with the Auth context of the logged in user.
const supabaseClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{
global: {
headers: { Authorization: req.headers.get('Authorization')! },
},
}
)
// Get the current user
const {
data: { user },
} = await supabaseClient.auth.getUser()
if (!user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
// Parse request body
const { target_user_id }: ConsumeOneTimePrekeyRequest = await req.json()
if (!target_user_id) {
return new Response(JSON.stringify({ error: 'target_user_id is required' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
console.log(`Consuming one-time pre-key for user: ${target_user_id}`)
// Get and consume (delete) the oldest one-time pre-key for the target user
const { data: prekey, error } = await supabaseClient
.from('one_time_prekeys')
.delete()
.eq('user_id', target_user_id)
.order('created_at')
.limit(1)
.select('id, public_key')
.single()
if (error) {
console.error('Error consuming one-time pre-key:', error)
return new Response(JSON.stringify({ error: 'Failed to consume one-time pre-key' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
if (!prekey) {
console.log(`No one-time pre-keys available for user: ${target_user_id}`)
// Return null to indicate no pre-key was available
return new Response(JSON.stringify(null), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
console.log(`Successfully consumed one-time pre-key: ${prekey.id} for user: ${target_user_id}`)
// Return the consumed pre-key data
return new Response(JSON.stringify({
key_id: prekey.id,
public_key: prekey.public_key,
}), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
} catch (error) {
console.error('Unexpected error:', error)
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
})

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,181 @@
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'
import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts'
import { trySignR2Url } from '../_shared/r2_signer.ts'
interface BeaconRequest {
lat: number
long: number
title: string
description: string
type: 'police' | 'checkpoint' | 'taskForce' | 'hazard' | 'safety' | 'community'
image_url?: string
}
interface ResponseData {
beacon?: Record<string, unknown>
error?: string
}
serve(async (req: Request) => {
try {
// Get auth header
const authHeader = req.headers.get('Authorization')
if (!authHeader) {
return new Response(
JSON.stringify({ error: 'Missing Authorization header' } as ResponseData),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
)
}
const supabase = createSupabaseClient(authHeader)
const { data: { user }, error: userError } = await supabase.auth.getUser()
if (userError || !user) {
console.error('Auth error:', userError)
return new Response(
JSON.stringify({ error: 'Unauthorized' } as ResponseData),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
)
}
// Use service role for DB operations
const supabaseAdmin = createServiceClient()
// Parse request body
const body = await req.json()
// Convert lat/long to numbers (handles both int and double from client)
const beaconReq: BeaconRequest = {
lat: Number(body.lat),
long: Number(body.long),
title: body.title,
description: body.description,
type: body.type,
image_url: body.image_url
}
// Validate required fields
if (!beaconReq.lat || !beaconReq.long || !beaconReq.title || !beaconReq.description || !beaconReq.type) {
return new Response(
JSON.stringify({ error: 'Missing required fields: lat, long, title, description, type' } as ResponseData),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
// Validate beacon type
const validTypes = ['police', 'checkpoint', 'taskForce', 'hazard', 'safety', 'community']
if (!validTypes.includes(beaconReq.type)) {
return new Response(
JSON.stringify({ error: 'Invalid beacon type. Must be: police, checkpoint, taskForce, hazard, safety, or community' } as ResponseData),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
// Get user's profile and trust score (use admin client to bypass RLS)
const { data: profile, error: profileError } = await supabaseAdmin
.from('profiles')
.select('id, trust_state(harmony_score)')
.eq('id', user.id)
.single()
if (profileError || !profile) {
return new Response(
JSON.stringify({ error: 'Profile not found' } as ResponseData),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
)
}
// Get a default category for beacons (search by slug to match seed data)
const { data: category } = await supabaseAdmin
.from('categories')
.select('id')
.eq('slug', 'beacon_alerts')
.single()
let categoryId = category?.id
if (!categoryId) {
// Create the beacon category if it doesn't exist (with service role bypass)
const { data: newCategory, error: insertError } = await supabaseAdmin
.from('categories')
.insert({ slug: 'beacon_alerts', name: 'Beacon Alerts', description: 'Community safety and alert posts' })
.select('id')
.single()
if (insertError || !newCategory) {
console.error('Failed to create beacon category:', insertError)
return new Response(
JSON.stringify({ error: 'Failed to create beacon category' } as ResponseData),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
categoryId = newCategory.id
}
// Get user's trust score for initial confidence
const trustScore = profile.trust_state?.harmony_score ?? 0.5
const initialConfidence = 0.5 + (trustScore * 0.3) // Start at 50-80% based on trust
// Create the beacon post
const { data: beacon, error: beaconError } = await supabaseAdmin
.from('posts')
.insert({
author_id: user.id,
category_id: categoryId,
body: beaconReq.description,
is_beacon: true,
beacon_type: beaconReq.type,
location: `SRID=4326;POINT(${beaconReq.long} ${beaconReq.lat})`,
confidence_score: Math.min(1.0, Math.max(0.0, initialConfidence)),
is_active_beacon: true,
image_url: beaconReq.image_url,
status: 'active',
tone_label: 'neutral',
cis_score: 0.8,
allow_chain: false // Beacons don't allow chaining
})
.select()
.single()
if (beaconError) {
console.error('Error creating beacon:', beaconError)
return new Response(
JSON.stringify({ error: `Failed to create beacon: ${beaconError.message}` } as ResponseData),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
// Get full beacon data with author info
const { data: fullBeacon } = await supabaseAdmin
.from('posts')
.select(`
*,
author:profiles!posts_author_id_fkey (
id,
handle,
display_name,
avatar_url
)
`)
.eq('id', beacon.id)
.single()
let signedBeacon = fullBeacon
if (fullBeacon?.image_url) {
signedBeacon = { ...fullBeacon, image_url: await trySignR2Url(fullBeacon.image_url) }
}
return new Response(
JSON.stringify({ beacon: signedBeacon } as ResponseData),
{ status: 201, headers: { 'Content-Type': 'application/json' } }
)
} catch (error) {
console.error('Unexpected error:', error)
return new Response(
JSON.stringify({ error: 'Internal server error' } as ResponseData),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
})

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,168 @@
/**
* POST /deactivate-account - Deactivate user account (reactivatable within 30 days)
* POST /deactivate-account/reactivate - Reactivate a deactivated account
*
* Design intent:
* - Allows users to temporarily deactivate their account
* - Account can be reactivated by logging in
* - Profile and posts are hidden while deactivated
*/
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createSupabaseClient } from '../_shared/supabase-client.ts';
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
},
});
}
try {
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
status: 401,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
const supabase = createSupabaseClient(authHeader);
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
if (req.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
const url = new URL(req.url);
const isReactivate = url.pathname.endsWith('/reactivate');
if (isReactivate) {
// Reactivate account
const { data, error } = await supabase
.rpc('reactivate_account', { p_user_id: user.id });
if (error) {
console.error('Error reactivating account:', error);
return new Response(JSON.stringify({
error: 'Failed to reactivate account',
details: error.message
}), {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
if (!data || !data.success) {
return new Response(JSON.stringify({
error: data?.error || 'Account is not deactivated'
}), {
status: 400,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
return new Response(
JSON.stringify({
success: true,
message: 'Account reactivated successfully',
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
}
);
} else {
// Deactivate account
const { data, error } = await supabase
.rpc('deactivate_account', { p_user_id: user.id });
if (error) {
console.error('Error deactivating account:', error);
return new Response(JSON.stringify({
error: 'Failed to deactivate account',
details: error.message
}), {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
if (!data || !data.success) {
return new Response(JSON.stringify({
error: data?.error || 'Account already deactivated'
}), {
status: 400,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
return new Response(
JSON.stringify({
success: true,
message: 'Account deactivated successfully. You can reactivate it anytime by logging in.',
deactivated_at: data.deactivated_at,
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
}
);
}
} catch (error) {
console.error('Unexpected error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,170 @@
/**
* POST /delete-account - Request permanent account deletion (30-day waiting period)
* POST /delete-account/cancel - Cancel a pending deletion request
*
* Design intent:
* - Allows users to request permanent account deletion
* - 30-day waiting period before actual deletion
* - Users can cancel the request within 30 days
* - After 30 days, account is permanently deleted by a scheduled job
*/
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createSupabaseClient } from '../_shared/supabase-client.ts';
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
},
});
}
try {
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
status: 401,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
const supabase = createSupabaseClient(authHeader);
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
if (req.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
const url = new URL(req.url);
const isCancel = url.pathname.endsWith('/cancel');
if (isCancel) {
// Cancel deletion request
const { data, error } = await supabase
.rpc('cancel_account_deletion', { p_user_id: user.id });
if (error) {
console.error('Error cancelling deletion:', error);
return new Response(JSON.stringify({
error: 'Failed to cancel deletion request',
details: error.message
}), {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
if (!data || !data.success) {
return new Response(JSON.stringify({
error: data?.error || 'No pending deletion request found'
}), {
status: 400,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
return new Response(
JSON.stringify({
success: true,
message: 'Account deletion request cancelled successfully',
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
}
);
} else {
// Request account deletion
const { data, error } = await supabase
.rpc('request_account_deletion', { p_user_id: user.id });
if (error) {
console.error('Error requesting deletion:', error);
return new Response(JSON.stringify({
error: 'Failed to request account deletion',
details: error.message
}), {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
if (!data || !data.success) {
return new Response(JSON.stringify({
error: data?.error || 'Account deletion already requested'
}), {
status: 400,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
return new Response(
JSON.stringify({
success: true,
message: 'Account deletion requested. Your account will be permanently deleted in 30 days. You can cancel this request anytime by logging in.',
deletion_date: data.deletion_date,
deletion_requested_at: data.deletion_requested_at,
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
}
);
}
} catch (error) {
console.error('Unexpected error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}
});

View file

@ -0,0 +1,5 @@
{
"compilerOptions": {
"lib": ["deno.ns", "dom"]
}
}

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,686 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
/**
* E2EE Session Manager Edge Function
*
* This function handles session management, cleanup, and recovery for the
* end-to-end encryption system. It operates on metadata only and cannot
* access the actual encrypted message content.
*
* Security Properties:
* - Blind to message content (only handles metadata)
* - Enforces proper session protocols
* - Handles cleanup and recovery scenarios
* - Maintains perfect forward secrecy
*/
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Content-Type': 'application/json',
};
serve(async (req) => {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
// Verify authorization header exists
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
status: 401,
headers: corsHeaders,
});
}
// Create auth client to verify the user's JWT
const authClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{
global: {
headers: {
Authorization: authHeader,
apikey: Deno.env.get('SUPABASE_ANON_KEY') ?? '',
},
},
}
);
// Verify the JWT and get the authenticated user
const { data: { user }, error: authError } = await authClient.auth.getUser();
if (authError || !user) {
console.error('[E2EE Session Manager] Auth error:', authError);
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: corsHeaders,
});
}
const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
if (!serviceRoleKey) {
console.error('[E2EE Session Manager] Missing SUPABASE_SERVICE_ROLE_KEY');
return new Response(JSON.stringify({ error: 'Server misconfiguration: missing service role key' }), {
status: 500,
headers: corsHeaders,
});
}
// Create service client for database operations (bypasses RLS)
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
serviceRoleKey
);
// Parse request body
const { action, userId, recipientId, conversationId, hasSession } = await req.json();
if (!action) {
return new Response(JSON.stringify({ error: 'Action is required' }), {
status: 400,
headers: corsHeaders,
});
}
// Use the authenticated user's ID, ignoring any userId in the request body
// This prevents users from impersonating others
const authenticatedUserId = user.id;
console.log(`[E2EE Session Manager] ${action} requested by ${authenticatedUserId}`);
switch (action) {
case 'reset_session':
if (!recipientId) {
return new Response(JSON.stringify({ error: 'recipientId is required' }), {
status: 400,
headers: corsHeaders,
});
}
if (recipientId === authenticatedUserId) {
return new Response(JSON.stringify({ error: 'recipientId must be different from userId' }), {
status: 400,
headers: corsHeaders,
});
}
{
const profileCheck = await ensureProfilesExist(supabase, [authenticatedUserId, recipientId]);
if (profileCheck) return profileCheck;
}
return handleResetSession(supabase, authenticatedUserId, recipientId, corsHeaders);
case 'cleanup_conversation':
if (!conversationId) {
return new Response(JSON.stringify({ error: 'conversationId is required' }), {
status: 400,
headers: corsHeaders,
});
}
return handleCleanupConversation(supabase, authenticatedUserId, conversationId, corsHeaders);
case 'verify_session':
if (!recipientId) {
return new Response(JSON.stringify({ error: 'recipientId is required' }), {
status: 400,
headers: corsHeaders,
});
}
if (recipientId === authenticatedUserId) {
return new Response(JSON.stringify({ error: 'recipientId must be different from userId' }), {
status: 400,
headers: corsHeaders,
});
}
{
const profileCheck = await ensureProfilesExist(supabase, [authenticatedUserId, recipientId]);
if (profileCheck) return profileCheck;
}
return handleVerifySession(supabase, authenticatedUserId, recipientId, corsHeaders);
case 'force_key_refresh':
return handleForceKeyRefresh(supabase, authenticatedUserId, corsHeaders);
case 'sync_session_state':
if (!recipientId) {
return new Response(JSON.stringify({ error: 'recipientId is required' }), {
status: 400,
headers: corsHeaders,
});
}
if (recipientId === authenticatedUserId) {
return new Response(JSON.stringify({ error: 'recipientId must be different from userId' }), {
status: 400,
headers: corsHeaders,
});
}
if (typeof hasSession !== 'boolean') {
return new Response(JSON.stringify({ error: 'hasSession must be a boolean' }), {
status: 400,
headers: corsHeaders,
});
}
{
const profileCheck = await ensureProfilesExist(supabase, [authenticatedUserId, recipientId]);
if (profileCheck) return profileCheck;
}
return handleSyncSessionState(supabase, authenticatedUserId, recipientId, hasSession, corsHeaders);
case 'get_session_state':
if (!recipientId) {
return new Response(JSON.stringify({ error: 'recipientId is required' }), {
status: 400,
headers: corsHeaders,
});
}
if (recipientId === authenticatedUserId) {
return new Response(JSON.stringify({ error: 'recipientId must be different from userId' }), {
status: 400,
headers: corsHeaders,
});
}
{
const profileCheck = await ensureProfilesExist(supabase, [authenticatedUserId, recipientId]);
if (profileCheck) return profileCheck;
}
return handleGetSessionState(supabase, authenticatedUserId, recipientId, corsHeaders);
default:
return new Response(JSON.stringify({ error: 'Invalid action' }), {
status: 400,
headers: corsHeaders,
});
}
} catch (error) {
console.error('[E2EE Session Manager] Error:', error);
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: corsHeaders,
});
}
});
async function ensureProfilesExist(
supabase: any,
userIds: string[]
): Promise<Response | null> {
const uniqueIds = [...new Set(userIds)].filter(Boolean);
if (uniqueIds.length === 0) {
return new Response(JSON.stringify({ error: 'Invalid user IDs' }), {
status: 400,
headers: corsHeaders,
});
}
const { data, error } = await supabase
.from('profiles')
.select('id')
.in('id', uniqueIds);
if (error) {
console.error('[E2EE Session Manager] Failed to verify profiles:', error);
return new Response(JSON.stringify({ error: 'Failed to verify profiles' }), {
status: 500,
headers: corsHeaders,
});
}
const found = new Set((data ?? []).map((row: { id: string }) => row.id));
const missing = uniqueIds.filter((id) => !found.has(id));
if (missing.length > 0) {
return new Response(JSON.stringify({ error: 'Profile not found', missingIds: missing }), {
status: 404,
headers: corsHeaders,
});
}
return null;
}
/**
* Reset session between two users
* - Clears session keys for both parties
* - Forces re-establishment of encryption session
*/
async function handleResetSession(supabase: any, userId: string, recipientId: string, headers: Record<string, string>) {
try {
console.log(`[E2EE Session Manager] Resetting session between ${userId} and ${recipientId}`);
// Note: We can't directly clear flutter_secure_storage from the server,
// but we can send commands that the client will process
// Store a session reset command in the database
const { error } = await supabase
.from('e2ee_session_commands')
.insert({
user_id: userId,
recipient_id: recipientId,
command_type: 'session_reset',
status: 'pending',
created_at: new Date().toISOString(),
});
if (error) {
console.error('[E2EE Session Manager] Failed to store session reset command:', error);
return new Response(JSON.stringify({ success: false, error: error.message }), {
status: 500,
headers,
});
}
// Send a realtime notification to both parties
// The client will pick this up and clear their local session keys
await supabase
.from('e2ee_session_events')
.insert({
user_id: userId,
event_type: 'session_reset',
recipient_id: recipientId,
timestamp: new Date().toISOString(),
});
await supabase
.from('e2ee_session_events')
.insert({
user_id: recipientId,
event_type: 'session_reset',
recipient_id: userId,
timestamp: new Date().toISOString(),
});
return new Response(JSON.stringify({
success: true,
message: 'Session reset initiated for both parties'
}), {
status: 200,
headers,
});
} catch (error) {
console.error('[E2EE Session Manager] Session reset failed:', error);
return new Response(JSON.stringify({ success: false, error: error.message }), {
status: 500,
headers,
});
}
}
/**
* Clean up conversation and related data
* - Deletes messages (if allowed by RLS)
* - Clears session keys
* - Handles cleanup of related metadata
*/
async function handleCleanupConversation(supabase: any, userId: string, conversationId: string, headers: Record<string, string>) {
try {
console.log(`[E2EE Session Manager] Cleaning up conversation ${conversationId} for ${userId}`);
// Get conversation details
const { data: conversation, error: convError } = await supabase
.from('encrypted_conversations')
.select('participant_a, participant_b')
.eq('id', conversationId)
.single();
if (convError || !conversation) {
console.error('[E2EE Session Manager] Conversation not found:', convError);
return new Response(JSON.stringify({ success: false, error: 'Conversation not found' }), {
status: 404,
headers,
});
}
// Determine the other participant
const otherUserId = conversation.participant_a === userId
? conversation.participant_b
: conversation.participant_a;
// Delete messages sent by the current user
const { error: deleteError } = await supabase
.from('encrypted_messages')
.delete()
.eq('conversation_id', conversationId)
.eq('sender_id', userId);
if (deleteError) {
console.warn('[E2EE Session Manager] Could not delete all messages:', deleteError);
// This is acceptable - RLS might prevent deletion of some messages
}
// Store cleanup command for both parties
await supabase
.from('e2ee_session_commands')
.insert({
user_id: userId,
conversation_id: conversationId,
command_type: 'conversation_cleanup',
status: 'pending',
created_at: new Date().toISOString(),
});
await supabase
.from('e2ee_session_commands')
.insert({
user_id: otherUserId,
conversation_id: conversationId,
command_type: 'conversation_cleanup',
status: 'pending',
created_at: new Date().toISOString(),
});
// Send realtime notifications
await supabase
.from('e2ee_session_events')
.insert({
user_id: userId,
event_type: 'conversation_cleanup',
conversation_id: conversationId,
timestamp: new Date().toISOString(),
});
await supabase
.from('e2ee_session_events')
.insert({
user_id: otherUserId,
event_type: 'conversation_cleanup',
conversation_id: conversationId,
timestamp: new Date().toISOString(),
});
return new Response(JSON.stringify({
success: true,
message: 'Conversation cleanup initiated',
otherUserId: otherUserId
}), {
status: 200,
headers,
});
} catch (error) {
console.error('[E2EE Session Manager] Conversation cleanup failed:', error);
return new Response(JSON.stringify({ success: false, error: error.message }), {
status: 500,
headers,
});
}
}
/**
* Verify if a session exists between two users
* - Checks for existing session metadata
* - Returns session status without exposing keys
*/
async function handleVerifySession(supabase: any, userId: string, recipientId: string, headers: Record<string, string>) {
try {
console.log(`[E2EE Session Manager] Verifying session between ${userId} and ${recipientId}`);
// Check if both users have signal keys (indicates they can establish sessions)
const { data: userKeys, error: userError } = await supabase
.from('signal_keys')
.select('user_id')
.eq('user_id', userId)
.single();
const { data: recipientKeys, error: recipientError } = await supabase
.from('signal_keys')
.select('user_id')
.eq('user_id', recipientId)
.single();
if (userError || !userKeys) {
return new Response(JSON.stringify({
success: false,
error: 'User has no encryption keys',
userHasKeys: false,
recipientHasKeys: !!recipientKeys
}), {
status: 400,
headers,
});
}
if (recipientError || !recipientKeys) {
return new Response(JSON.stringify({
success: false,
error: 'Recipient has no encryption keys',
userHasKeys: true,
recipientHasKeys: false
}), {
status: 400,
headers,
});
}
// Check if there's an existing conversation (indicates potential session)
const { data: conversation, error: convError } = await supabase
.from('encrypted_conversations')
.select('id, last_message_at')
.or(`participant_a.eq.${userId},participant_b.eq.${userId}`)
.or(`participant_a.eq.${recipientId},participant_b.eq.${recipientId}`)
.single();
return new Response(JSON.stringify({
success: true,
userHasKeys: true,
recipientHasKeys: true,
hasConversation: !!conversation,
conversationId: conversation?.id,
lastMessageAt: conversation?.last_message_at
}), {
status: 200,
headers,
});
} catch (error) {
console.error('[E2EE Session Manager] Session verification failed:', error);
return new Response(JSON.stringify({ success: false, error: error.message }), {
status: 500,
headers,
});
}
}
/**
* Force key refresh for a user
* - Triggers rotation of encryption keys
* - Handles key upload and cleanup
*/
async function handleForceKeyRefresh(supabase: any, userId: string, headers: Record<string, string>) {
try {
console.log(`[E2EE Session Manager] Forcing key refresh for ${userId}`);
// Store a key refresh command
const { error } = await supabase
.from('e2ee_session_commands')
.insert({
user_id: userId,
command_type: 'key_refresh',
status: 'pending',
created_at: new Date().toISOString(),
});
if (error) {
console.error('[E2EE Session Manager] Failed to store key refresh command:', error);
return new Response(JSON.stringify({ success: false, error: error.message }), {
status: 500,
headers,
});
}
// Send realtime notification
await supabase
.from('e2ee_session_events')
.insert({
user_id: userId,
event_type: 'key_refresh',
timestamp: new Date().toISOString(),
});
return new Response(JSON.stringify({
success: true,
message: 'Key refresh initiated',
note: 'Client should generate new keys and upload them'
}), {
status: 200,
headers,
});
} catch (error) {
console.error('[E2EE Session Manager] Key refresh failed:', error);
return new Response(JSON.stringify({ success: false, error: error.message }), {
status: 500,
headers,
});
}
}
/**
* Sync session state between two users
* - Updates the server-side session state tracking
* - Detects mismatches and triggers recovery if needed
*/
async function handleSyncSessionState(
supabase: any,
userId: string,
recipientId: string,
hasSession: boolean,
headers: Record<string, string>
) {
try {
console.log(`[E2EE Session Manager] Syncing session state: ${userId} -> ${recipientId}, hasSession: ${hasSession}`);
if (!recipientId) {
return new Response(JSON.stringify({ error: 'recipientId is required' }), {
status: 400,
headers,
});
}
// Call the database function to update session state
const { data, error } = await supabase.rpc('update_e2ee_session_state', {
p_user_id: userId,
p_peer_id: recipientId,
p_has_session: hasSession,
});
if (error) {
console.error('[E2EE Session Manager] Failed to update session state:', error);
return new Response(JSON.stringify({ success: false, error: error.message }), {
status: 500,
headers,
});
}
const result = data as {
success: boolean;
user_has_session: boolean;
peer_has_session: boolean;
session_mismatch: boolean;
peer_session_version: number;
};
// If there's a mismatch, notify both parties
if (result.session_mismatch) {
console.log(`[E2EE Session Manager] Session mismatch detected between ${userId} and ${recipientId}`);
// Insert session_mismatch event for both users
await supabase.from('e2ee_session_events').insert([
{
user_id: userId,
event_type: 'session_mismatch',
recipient_id: recipientId,
error_details: {
user_has_session: result.user_has_session,
peer_has_session: result.peer_has_session,
},
timestamp: new Date().toISOString(),
},
{
user_id: recipientId,
event_type: 'session_mismatch',
recipient_id: userId,
error_details: {
user_has_session: result.peer_has_session,
peer_has_session: result.user_has_session,
},
timestamp: new Date().toISOString(),
},
]);
}
return new Response(JSON.stringify({
success: true,
userHasSession: result.user_has_session,
peerHasSession: result.peer_has_session,
sessionMismatch: result.session_mismatch,
peerSessionVersion: result.peer_session_version,
}), {
status: 200,
headers,
});
} catch (error) {
console.error('[E2EE Session Manager] Sync session state failed:', error);
return new Response(JSON.stringify({ success: false, error: error.message }), {
status: 500,
headers,
});
}
}
/**
* Get current session state between two users
* - Returns session state without modifying it
* - Used to check for mismatches before sending messages
*/
async function handleGetSessionState(
supabase: any,
userId: string,
recipientId: string,
headers: Record<string, string>
) {
try {
console.log(`[E2EE Session Manager] Getting session state: ${userId} <-> ${recipientId}`);
if (!recipientId) {
return new Response(JSON.stringify({ error: 'recipientId is required' }), {
status: 400,
headers,
});
}
// Call the database function to get session state
const { data, error } = await supabase.rpc('get_e2ee_session_state', {
p_user_id: userId,
p_peer_id: recipientId,
});
if (error) {
console.error('[E2EE Session Manager] Failed to get session state:', error);
return new Response(JSON.stringify({ success: false, error: error.message }), {
status: 500,
headers,
});
}
const result = data as {
exists: boolean;
user_has_session: boolean;
peer_has_session: boolean;
session_mismatch: boolean;
user_session_version?: number;
peer_session_version?: number;
};
return new Response(JSON.stringify({
success: true,
exists: result.exists,
userHasSession: result.user_has_session,
peerHasSession: result.peer_has_session,
sessionMismatch: result.session_mismatch,
userSessionVersion: result.user_session_version,
peerSessionVersion: result.peer_session_version,
}), {
status: 200,
headers,
});
} catch (error) {
console.error('[E2EE Session Manager] Get session state failed:', error);
return new Response(JSON.stringify({ success: false, error: error.message }), {
status: 500,
headers,
});
}
}

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,149 @@
import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
import { createSupabaseClient, createServiceClient } from "../_shared/supabase-client.ts";
import { trySignR2Url } from "../_shared/r2_signer.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
"Vary": "Origin",
};
serve(async (req: Request) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
console.error("Missing authorization header");
return new Response(JSON.stringify({ error: "Missing authorization header" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
const supabase = createSupabaseClient(authHeader);
// Don't pass JWT explicitly - let the SDK validate using its internal session
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
console.error("Auth error in feed-personal:", authError);
console.error("User object:", user);
return new Response(JSON.stringify({ error: "Unauthorized", details: authError?.message || "No user returned" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!serviceKey) {
console.error("Missing SUPABASE_SERVICE_ROLE_KEY in function environment");
return new Response(JSON.stringify({ error: "Server misconfigured: service key missing" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
// Use service client for database queries to bypass RLS
const serviceClient = createServiceClient();
const url = new URL(req.url);
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 100);
const offset = parseInt(url.searchParams.get("offset") || "0");
// Check if user has opted into beacon posts
const { data: profile } = await serviceClient
.from("profiles")
.select("beacon_enabled")
.eq("id", user.id)
.single();
const beaconEnabled = profile?.beacon_enabled || false;
// Get list of users this person follows (with accepted status)
const { data: followingData } = await serviceClient
.from("follows")
.select("following_id")
.eq("follower_id", user.id)
.eq("status", "accepted");
const followingIds = (followingData || []).map((f: any) => f.following_id);
// Include user's own posts in their feed + posts from people they follow
const authorIds = [user.id, ...followingIds];
// Debug: First try a simple query to see if the basic setup works
console.log("Debug: About to query posts for user:", user.id);
console.log("Debug: Author IDs:", authorIds);
console.log("Debug: Beacon enabled:", beaconEnabled);
// Fetch posts from followed users and self
let postsQuery = serviceClient
.from("posts")
.select(`id, type, body, created_at, visibility, author_id,
author:profiles!posts_author_id_fkey (id, handle, display_name, avatar_url)`)
.in("author_id", authorIds);
// .eq("status", "active"); // Temporarily remove status filter to debug
// Filter visibility: user can see their own posts (any visibility),
// or public/followers posts from people they follow
postsQuery = postsQuery.or(`author_id.eq.${user.id},visibility.in.(public,followers)`);
// Only filter out beacons if user has NOT opted in
if (!beaconEnabled) {
postsQuery = postsQuery.eq("is_beacon", false);
}
const { data: posts, error: postsError } = await postsQuery
.order("created_at", { ascending: false })
.range(offset, offset + limit - 1);
if (postsError) {
console.error("Error fetching posts:", postsError);
return new Response(JSON.stringify({ error: "Failed to fetch feed" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
// Get chain parent posts separately (self-referential relationship not set up)
const postsWithChains = posts || [];
const chainParentIds = postsWithChains
.filter((p: any) => p.chain_parent_id)
.map((p: any) => p.chain_parent_id);
let chainParentMap = new Map<string, any>();
if (chainParentIds.length > 0) {
const { data: chainParents } = await serviceClient
.from("posts")
.select(`id, body, created_at,
author:profiles!posts_author_id_fkey (id, handle, display_name, avatar_url)`)
.in("id", [...new Set(chainParentIds)]);
chainParents?.forEach((cp: any) => {
chainParentMap.set(cp.id, {
id: cp.id,
body: cp.body,
created_at: cp.created_at,
author: cp.author,
});
});
}
const feedItems = postsWithChains.map((post: any) => ({
id: post.id, body: post.body, body_format: post.body_format, background_id: post.background_id, created_at: post.created_at, tone_label: post.tone_label,
allow_chain: post.allow_chain, chain_parent_id: post.chain_parent_id,
image_url: post.image_url,
visibility: post.visibility,
chain_parent: post.chain_parent_id ? chainParentMap.get(post.chain_parent_id) : null,
author: post.author, category: post.category, metrics: post.metrics,
user_liked: post.user_liked?.some((l: any) => l.user_id === user.id) || false,
user_saved: post.user_saved?.some((s: any) => s.user_id === user.id) || false,
}));
const signedItems = await Promise.all(
feedItems.map(async (post) => {
if (!post.image_url) {
return post;
}
return { ...post, image_url: await trySignR2Url(post.image_url) };
})
);
return new Response(JSON.stringify({ posts: signedItems, pagination: { limit, offset, returned: signedItems.length } }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } });
} catch (error) {
console.error("Unexpected error:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,363 @@
import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
import { createSupabaseClient, createServiceClient } from "../_shared/supabase-client.ts";
import { rankPosts, type PostForRanking } from "../_shared/ranking.ts";
import { trySignR2Url } from "../_shared/r2_signer.ts";
const ALLOWED_ORIGIN = Deno.env.get("ALLOWED_ORIGIN") || "*";
const corsHeaders = {
"Access-Control-Allow-Origin": ALLOWED_ORIGIN,
"Access-Control-Allow-Methods": "GET",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
interface Profile { id: string; handle: string; display_name: string; }
interface Category { id: string; slug: string; name: string; }
interface PostMetrics { like_count: number; save_count: number; view_count: number; }
interface Post {
id: string; body: string; body_format?: string; background_id?: string; created_at: string; category_id: string | null;
tone_label: "positive" | "neutral" | "mixed" | "negative";
cis_score: number; author_id: string; author: Profile; category: Category;
metrics: PostMetrics | null; allow_chain: boolean; chain_parent_id: string | null;
image_url: string | null; tags: string[] | null; visibility?: string;
user_liked: { user_id: string }[]; user_saved: { user_id: string }[];
// Sponsored ad fields
is_sponsored?: boolean;
advertiser_name?: string;
advertiser_cta_link?: string;
advertiser_cta_text?: string;
advertiser_body?: string;
advertiser_image_url?: string;
}
interface TrustState { user_id: string; harmony_score: number; tier: "new" | "standard" | "trusted"; }
interface Block { blocked_id: string; }
interface Report { target_id: string; reporter_id: string; status: "pending" | "resolved"; }
interface PostLike { user_id: string; }
interface PostSave { user_id: string; }
interface SponsoredPost {
id: string;
advertiser_name: string;
body: string;
image_url: string | null;
cta_link: string;
cta_text: string;
}
serve(async (req: Request) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
console.error("Missing authorization header");
return new Response(JSON.stringify({ error: "Missing authorization header" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
console.log("Auth header present, length:", authHeader.length);
// Extract JWT from header
const jwt = authHeader.replace("Bearer ", "");
console.log("JWT extracted, length:", jwt.length);
const supabase = createSupabaseClient(authHeader);
console.log("Supabase client created");
const serviceClient = createServiceClient();
console.log("Service client created");
// Don't pass JWT explicitly - let the SDK validate using its internal session
// The auth header was already used to create the client
const { data: { user }, error: authError } = await supabase.auth.getUser();
console.log("getUser result:", { userId: user?.id, error: authError });
if (authError || !user) {
console.error("Auth error in feed-sojorn:", authError);
console.error("User object:", user);
return new Response(JSON.stringify({ error: "Unauthorized", details: authError?.message || "No user returned" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
const url = new URL(req.url);
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 100);
const offset = parseInt(url.searchParams.get("offset") || "0");
// Check if user has opted into beacon posts
const { data: profile } = await serviceClient
.from("profiles")
.select("beacon_enabled")
.eq("id", user.id)
.single();
const beaconEnabled = profile?.beacon_enabled || false;
// Get user's enabled category IDs for ad targeting
const { data: userCategorySettings, error: userCategoryError } = await serviceClient
.from("user_category_settings")
.select("category_id")
.eq("user_id", user.id)
.eq("enabled", true);
if (userCategoryError) {
console.error("Error fetching user category settings:", userCategoryError);
}
const userCategoryIds = (userCategorySettings || [])
.map((uc) => uc.category_id)
.filter(Boolean);
// Map categories to their slugs for tag matching (normalized lowercase)
let userCategorySlugs: string[] = [];
if (userCategoryIds.length > 0) {
const { data: categories } = await serviceClient
.from("categories")
.select("id, slug")
.in("id", userCategoryIds);
userCategorySlugs = (categories || [])
.map((c: Category) => (c.slug || "").toLowerCase())
.filter((slug) => slug.length > 0);
}
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
// Fetch posts prioritizing those with images first, then by recency
// This ensures visual content gets featured prominently
// Exclude beacon posts unless user has opted in
let postsQuery = serviceClient
.from("posts")
.select(`id, body, body_format, created_at, category_id, tone_label, cis_score, author_id, image_url, tags, visibility,
author:profiles!posts_author_id_fkey (id, handle, display_name, avatar_url),
category:categories!posts_category_id_fkey (id, slug, name),
metrics:post_metrics (like_count, save_count)`)
.in("tone_label", ["positive", "neutral", "mixed"])
.gte("created_at", sevenDaysAgo);
// Hybrid matching: legacy categories OR new hashtag tags
if (userCategoryIds.length > 0 || userCategorySlugs.length > 0) {
const orConditions: string[] = [];
if (userCategoryIds.length > 0) {
orConditions.push(`category_id.in.(${userCategoryIds.join(",")})`);
}
if (userCategorySlugs.length > 0) {
orConditions.push(`tags.ov.{${userCategorySlugs.join(",")}}`);
}
if (orConditions.length > 0) {
postsQuery = postsQuery.or(orConditions.join(","));
}
}
// Only filter out beacons if user has NOT opted in
if (!beaconEnabled) {
postsQuery = postsQuery.eq("is_beacon", false);
}
const { data: posts, error: postsError } = await postsQuery
.order("image_url", { ascending: false }) // Posts WITH images first
.order("created_at", { ascending: false })
.limit(1000); // Fetch more to rank, then paginate
if (postsError) {
console.error("Error fetching posts:", postsError);
return new Response(JSON.stringify({
error: "Failed to fetch feed",
details: postsError.message,
code: postsError.code,
hint: postsError.hint,
}), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
const safePosts = posts || [];
const authorIds = [...new Set(safePosts.map((p: Post) => p.author_id))];
const trustStates = authorIds.length > 0
? (await serviceClient.from("trust_state").select("user_id, harmony_score, tier").in("user_id", authorIds)).data
: [];
const trustMap = new Map<string, TrustState>(trustStates?.map((t: TrustState) => [t.user_id, t]) || []);
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const recentBlocks = authorIds.length > 0
? (await serviceClient.from("blocks").select("blocked_id").in("blocked_id", authorIds).gte("created_at", oneDayAgo)).data
: [];
const blocksMap = new Map<string, number>();
recentBlocks?.forEach((block: Block) => { blocksMap.set(block.blocked_id, (blocksMap.get(block.blocked_id) || 0) + 1); });
const postIds = safePosts.map((p: Post) => p.id);
const reports = postIds.length > 0
? (await serviceClient.from("reports").select("target_id, reporter_id, status").eq("target_type", "post").in("target_id", postIds)).data
: [];
const trustedReportMap = new Map<string, number>();
const totalReportMap = new Map<string, number>();
for (const report of reports || []) {
totalReportMap.set(report.target_id, (totalReportMap.get(report.target_id) || 0) + 1);
const reporterTrust = trustMap.get(report.reporter_id);
if (reporterTrust && reporterTrust.harmony_score >= 70) {
trustedReportMap.set(report.target_id, (trustedReportMap.get(report.target_id) || 0) + 1);
}
}
// Calculate has_image bonus for ranking
const postsForRanking: PostForRanking[] = safePosts.map((post: Post) => {
const authorTrust = trustMap.get(post.author_id);
return {
id: post.id,
created_at: post.created_at,
cis_score: post.cis_score || 0.8,
tone_label: post.tone_label || "neutral",
save_count: post.metrics?.save_count || 0,
like_count: post.metrics?.like_count || 0,
view_count: post.metrics?.view_count || 0,
author_harmony_score: authorTrust?.harmony_score || 50,
author_tier: authorTrust?.tier || "new",
blocks_received_24h: blocksMap.get(post.author_id) || 0,
trusted_reports: trustedReportMap.get(post.id) || 0,
total_reports: totalReportMap.get(post.id) || 0,
has_image: post.image_url != null && post.image_url.length > 0,
};
});
// Use ranking algorithm
const rankedPosts = rankPosts(postsForRanking);
const paginatedPosts = rankedPosts.slice(offset, offset + limit);
const resultIds = paginatedPosts.map((p) => p.id);
let finalPosts: Post[] = [];
if (resultIds.length > 0) {
const { data, error: finalError } = await serviceClient
.from("posts")
.select(`id, body, body_format, background_id, created_at, tone_label, allow_chain, chain_parent_id, image_url, tags, visibility,
author:profiles!posts_author_id_fkey (id, handle, display_name, avatar_url),
category:categories!posts_category_id_fkey (id, slug, name),
metrics:post_metrics (like_count, save_count),
user_liked:post_likes!left (user_id),
user_saved:post_saves!left (user_id)`)
.in("id", resultIds);
if (finalError) {
console.error("Error fetching final posts:", finalError);
return new Response(JSON.stringify({
error: "Failed to fetch feed",
details: finalError.message,
code: finalError.code,
hint: finalError.hint,
}), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
finalPosts = data || [];
}
let orderedPosts = resultIds.map((id) => finalPosts.find((p: Post) => p.id === id)).filter(Boolean).map((post: Post) => ({
id: post.id, body: post.body, body_format: post.body_format, background_id: post.background_id, created_at: post.created_at, tone_label: post.tone_label, allow_chain: post.allow_chain,
chain_parent_id: post.chain_parent_id, image_url: post.image_url, tags: post.tags, visibility: post.visibility, author: post.author, category: post.category, metrics: post.metrics,
user_liked: post.user_liked?.some((l: PostLike) => l.user_id === user.id) || false,
user_saved: post.user_saved?.some((s: PostSave) => s.user_id === user.id) || false,
}));
// =========================================================================
// SILENT AD INJECTION - Check for sponsored content
// =========================================================================
// Only inject if user has categories and we have posts to inject into
if (userCategoryIds.length > 0 && orderedPosts.length > 0) {
try {
// Fetch a random active ad that matches user's subscribed categories
const { data: sponsoredPosts } = await serviceClient
.from("sponsored_posts")
.select("id, advertiser_name, body, image_url, cta_link, cta_text")
.eq("active", true)
.or(
`target_categories.cs.{*},target_categories.ov.{${userCategoryIds.join(",")}}`,
)
.lt("current_impressions", serviceClient.raw("impression_goal")) // Only show if under goal
.limit(1);
if (sponsoredPosts && sponsoredPosts.length > 0) {
const ad = sponsoredPosts[0] as SponsoredPost;
// Create a fake post object that looks like a real post but is marked as sponsored
const sponsoredPost: Post = {
id: ad.id,
body: ad.body,
body_format: "markdown",
background_id: null,
created_at: new Date().toISOString(),
tone_label: "neutral",
cis_score: 1.0,
author_id: "sponsored",
author: {
id: "sponsored",
handle: "sponsored",
display_name: ad.advertiser_name,
},
category: {
id: "sponsored",
slug: "sponsored",
name: "Sponsored",
},
metrics: { like_count: 0, save_count: 0, view_count: 0 },
allow_chain: false,
chain_parent_id: null,
image_url: ad.image_url,
tags: null,
user_liked: [],
user_saved: [],
// Sponsored ad markers
is_sponsored: true,
advertiser_name: ad.advertiser_name,
advertiser_cta_link: ad.cta_link,
advertiser_cta_text: ad.cta_text,
advertiser_body: ad.body,
advertiser_image_url: ad.image_url,
};
// Inject at position 4 (5th slot) if we have enough posts
const adInjectionIndex = 4;
if (orderedPosts.length > adInjectionIndex) {
orderedPosts = [
...orderedPosts.slice(0, adInjectionIndex),
sponsoredPost,
...orderedPosts.slice(adInjectionIndex),
];
console.log("Sponsored ad injected at index", adInjectionIndex, "advertiser:", ad.advertiser_name);
} else {
// If not enough posts, inject at the end
orderedPosts = [...orderedPosts, sponsoredPost];
console.log("Sponsored ad injected at end, advertiser:", ad.advertiser_name);
}
}
} catch (error) {
console.error("Sponsored ad injection failed:", error);
}
}
// =========================================================================
// END AD INJECTION
// =========================================================================
const signedPosts = await Promise.all(
orderedPosts.map(async (post) => {
if (!post.image_url && !post.advertiser_image_url) {
return post;
}
const nextPost = { ...post };
if (post.image_url) {
nextPost.image_url = await trySignR2Url(post.image_url);
}
if (post.advertiser_image_url) {
nextPost.advertiser_image_url = await trySignR2Url(post.advertiser_image_url);
}
return nextPost;
})
);
return new Response(JSON.stringify({
posts: signedPosts,
pagination: { limit, offset, returned: orderedPosts.length },
ranking_explanation: "Posts are ranked by: 1) Has image bonus, 2) Author harmony score, 3) Steady appreciation (saves > likes), 4) Recency. Sponsored content may appear periodically.",
}), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } });
} catch (error) {
console.error("Unexpected error:", error);
return new Response(JSON.stringify({
error: "Internal server error",
details: error instanceof Error ? error.message : String(error),
}), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,135 @@
/**
* POST /follow - Follow a user
* DELETE /follow - Unfollow a user
*
* Design intent:
* - Following is explicit and intentional
* - Mutual follow enables conversation
* - Cannot follow if blocked
*/
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createSupabaseClient } from '../_shared/supabase-client.ts';
import { validateUUID, ValidationError } from '../_shared/validation.ts';
interface FollowRequest {
user_id: string; // the user to follow/unfollow
}
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, DELETE',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
},
});
}
try {
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const supabase = createSupabaseClient(authHeader);
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
let { user_id: following_id } = (await req.json()) as FollowRequest;
validateUUID(following_id, 'user_id');
following_id = following_id.toLowerCase();
if (following_id === user.id.toLowerCase()) {
return new Response(
JSON.stringify({ error: 'Cannot follow yourself' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Handle unfollow
if (req.method === 'DELETE') {
const { error: deleteError } = await supabase
.from('follows')
.delete()
.eq('follower_id', user.id)
.eq('following_id', following_id);
if (deleteError) {
console.error('Error unfollowing:', deleteError);
return new Response(JSON.stringify({ error: 'Failed to unfollow' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true, message: 'Unfollowed' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// Handle follow (POST) via request_follow
const { data: status, error: followError } = await supabase
.rpc('request_follow', { target_id: following_id });
if (followError) {
if (followError.message?.includes('Cannot follow') || followError.code === '23514') {
return new Response(
JSON.stringify({ error: 'Cannot follow this user' }),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
console.error('Error following:', followError);
return new Response(JSON.stringify({ error: 'Failed to follow' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
const followStatus = status as string | null;
const message =
followStatus === 'pending'
? 'Request sent.'
: 'Followed. Mutual follow enables conversation.';
return new Response(
JSON.stringify({
success: true,
status: followStatus,
message,
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
} catch (error) {
if (error instanceof ValidationError) {
return new Response(
JSON.stringify({ error: 'Validation error', message: error.message }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
console.error('Unexpected error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,318 @@
/// <reference types="https://deno.land/x/deno@v1.28.0/cli/dts/lib.deno.ts" />
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts';
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
const corsHeaders = {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
// ============================================================================
// TONE CHECK (inline version to avoid external call)
// ============================================================================
interface ToneResult {
tone: 'positive' | 'neutral' | 'mixed' | 'negative' | 'hostile' | 'hate';
cis: number;
flags: string[];
reason: string;
}
function basicModeration(text: string): ToneResult {
const lowerText = text.toLowerCase();
const flags: string[] = [];
// Slur patterns
const slurPatterns = [/\bn+[i1]+g+[aegr]+/i, /\bf+[a4]+g+[s$o0]+t/i, /\br+[e3]+t+[a4]+r+d/i];
for (const pattern of slurPatterns) {
if (pattern.test(text)) {
return { tone: 'hate', cis: 0.0, flags: ['hate-speech'], reason: 'Hate speech detected.' };
}
}
// Attack patterns
const attackPatterns = [/\b(fuck|screw|damn)\s+(you|u|your|ur)/i, /\b(kill|hurt|attack)\s+(you|yourself)/i];
for (const pattern of attackPatterns) {
if (pattern.test(text)) {
return { tone: 'hostile', cis: 0.2, flags: ['hostile'], reason: 'Personal attack detected.' };
}
}
// Positive indicators
const positiveWords = ['thank', 'appreciate', 'love', 'support', 'grateful'];
if (positiveWords.some(word => lowerText.includes(word))) {
return { tone: 'positive', cis: 1.0, flags: [], reason: 'Positive content' };
}
return { tone: 'neutral', cis: 0.8, flags: [], reason: 'Content approved' };
}
async function checkTone(text: string): Promise<ToneResult> {
const openAiKey = Deno.env.get('OPEN_AI');
if (openAiKey) {
try {
const response = await fetch('https://api.openai.com/v1/moderations', {
method: 'POST',
headers: { 'Authorization': `Bearer ${openAiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ input: text, model: 'text-moderation-latest' }),
});
if (response.ok) {
const data = await response.json();
const results = data.results?.[0];
if (results) {
if (results.flagged) {
if (results.categories?.hate) return { tone: 'hate', cis: 0.0, flags: ['hate'], reason: 'Hate speech detected.' };
if (results.categories?.harassment) return { tone: 'hostile', cis: 0.2, flags: ['harassment'], reason: 'Harassment detected.' };
return { tone: 'mixed', cis: 0.5, flags: ['flagged'], reason: 'Content flagged.' };
}
// Not flagged - return neutral with CIS based on scores
const maxScore = Math.max(results.category_scores?.harassment || 0, results.category_scores?.hate || 0);
if (maxScore > 0.5) return { tone: 'mixed', cis: 0.6, flags: [], reason: 'Content approved (caution)' };
return { tone: 'neutral', cis: 0.8, flags: [], reason: 'Content approved' };
}
}
} catch (error) {
console.error('OpenAI moderation error:', error);
}
}
return basicModeration(text);
}
// ============================================================================
// MAIN HANDLER
// ============================================================================
interface RequestBody {
action: 'edit' | 'delete' | 'update_privacy' | 'bulk_update_privacy' | 'pin' | 'unpin';
post_id?: string;
content?: string; // For edit action
visibility?: 'public' | 'followers' | 'private';
}
serve(async (req: Request) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: { ...corsHeaders, 'Access-Control-Allow-Methods': 'POST OPTIONS' } });
}
try {
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing authorization' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
const supabase = createSupabaseClient(authHeader);
const serviceClient = createServiceClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
const { action, post_id, content, visibility } = await req.json() as RequestBody;
if (!action) {
return new Response(JSON.stringify({ error: 'Missing required fields' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
const allowedVisibilities = ['public', 'followers', 'private'];
if ((action === 'update_privacy' || action === 'bulk_update_privacy') && (!visibility || !allowedVisibilities.includes(visibility))) {
return new Response(JSON.stringify({ error: 'Invalid visibility' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
if (action === 'bulk_update_privacy') {
const { error: bulkError } = await serviceClient
.from('posts')
.update({ visibility })
.eq('author_id', user.id);
if (bulkError) {
console.error('Bulk privacy update error:', bulkError);
return new Response(JSON.stringify({ error: 'Failed to update post privacy' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({ success: true, message: 'Post privacy updated' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
if (!post_id) {
return new Response(JSON.stringify({ error: 'Missing post_id' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
// Fetch the post
const { data: post, error: postError } = await serviceClient
.from('posts')
.select('id, author_id, body, tone_label, cis_score, created_at, is_edited')
.eq('id', post_id)
.single();
if (postError || !post) {
return new Response(JSON.stringify({ error: 'Post not found' }), { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
// Verify ownership
if (post.author_id !== user.id) {
return new Response(JSON.stringify({ error: 'You can only edit or delete your own posts' }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
// ========================================================================
// ACTION: PIN
// ========================================================================
if (action === 'pin') {
const pinnedAt = new Date().toISOString();
const { error: clearError } = await serviceClient
.from('posts')
.update({ pinned_at: null })
.eq('author_id', user.id);
if (clearError) {
console.error('Clear pinned post error:', clearError);
return new Response(JSON.stringify({ error: 'Failed to pin post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
const { error: pinError } = await serviceClient
.from('posts')
.update({ pinned_at: pinnedAt })
.eq('id', post_id);
if (pinError) {
console.error('Pin post error:', pinError);
return new Response(JSON.stringify({ error: 'Failed to pin post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({ success: true, message: 'Post pinned' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
// ========================================================================
// ACTION: UNPIN
// ========================================================================
if (action === 'unpin') {
const { error: unpinError } = await serviceClient
.from('posts')
.update({ pinned_at: null })
.eq('id', post_id);
if (unpinError) {
console.error('Unpin post error:', unpinError);
return new Response(JSON.stringify({ error: 'Failed to unpin post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({ success: true, message: 'Post unpinned' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
// ========================================================================
// ACTION: UPDATE PRIVACY
// ========================================================================
if (action === 'update_privacy') {
const { error: privacyError } = await serviceClient
.from('posts')
.update({ visibility })
.eq('id', post_id);
if (privacyError) {
console.error('Privacy update error:', privacyError);
return new Response(JSON.stringify({ error: 'Failed to update post privacy' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({ success: true, message: 'Post privacy updated' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
// ========================================================================
// ACTION: DELETE (Hard Delete)
// ========================================================================
if (action === 'delete') {
const { error: deleteError } = await serviceClient
.from('posts')
.delete()
.eq('id', post_id);
if (deleteError) {
console.error('Delete error:', deleteError);
return new Response(JSON.stringify({ error: 'Failed to delete post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({ success: true, message: 'Post deleted successfully' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
// ========================================================================
// ACTION: EDIT
// ========================================================================
if (action === 'edit') {
if (!content || content.trim().length === 0) {
return new Response(JSON.stringify({ error: 'Content is required for edits' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
// 1. Time Check: 2-minute window
const createdAt = new Date(post.created_at);
const now = new Date();
const twoMinutesAgo = new Date(now.getTime() - 2 * 60 * 1000);
if (createdAt < twoMinutesAgo) {
return new Response(JSON.stringify({ error: 'Edit window expired. Posts can only be edited within 2 minutes of creation.' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
// 2. Check if already edited (one edit only)
if (post.is_edited) {
return new Response(JSON.stringify({ error: 'Post has already been edited' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
// 3. Moderation Check: Run new content through tone check
const moderation = await checkTone(content);
if (moderation.tone === 'hate' || moderation.tone === 'hostile') {
return new Response(JSON.stringify({
error: 'Edit rejected: Content does not meet community guidelines.',
details: moderation.reason
}), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
// 4. Archive: Save current content to post_versions
const { error: archiveError } = await serviceClient
.from('post_versions')
.insert({
post_id: post_id,
content: post.body,
tone_label: post.tone_label,
cis_score: post.cis_score,
});
if (archiveError) {
console.error('Archive error:', archiveError);
return new Response(JSON.stringify({ error: 'Failed to save edit history' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
// 5. Update: Apply new content
const { error: updateError } = await serviceClient
.from('posts')
.update({
body: content,
tone_label: moderation.tone,
cis_score: moderation.cis,
is_edited: true,
edited_at: now.toISOString(),
})
.eq('id', post_id);
if (updateError) {
console.error('Update error:', updateError);
return new Response(JSON.stringify({ error: 'Failed to update post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({
success: true,
message: 'Post updated successfully',
moderation: { tone: moderation.tone, cis: moderation.cis }
}), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({ error: 'Invalid action' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
} catch (error) {
console.error('Unexpected error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,244 @@
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
interface NotificationRow {
id: string;
user_id: string;
type: 'follow' | 'appreciate' | 'comment' | 'chain' | 'mention' | 'follow_request' | 'new_follower' | 'request_accepted';
actor_id: string;
post_id: string | null;
comment_id: string | null;
metadata: Record<string, unknown>;
is_read: boolean;
created_at: string;
}
interface NotificationResponse extends NotificationRow {
actor: {
id: string;
handle: string;
display_name: string;
avatar_url: string | null;
};
post?: {
id: string;
body: string;
} | null;
}
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '';
const anonKey = Deno.env.get('SUPABASE_ANON_KEY') ?? '';
const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '';
const supabaseClient = createClient(supabaseUrl, anonKey, {
global: {
headers: {
Authorization: req.headers.get('Authorization') ?? '',
apikey: anonKey,
},
},
});
const adminClient = createClient(supabaseUrl, serviceRoleKey);
// Get authenticated user (explicit token from header)
const authHeader =
req.headers.get('Authorization') ?? req.headers.get('authorization') ?? '';
const token = authHeader.startsWith('Bearer ')
? authHeader.slice('Bearer '.length)
: authHeader;
const {
data: { user },
error: authError,
} = await supabaseClient.auth.getUser(token || undefined);
if (authError || !user) {
console.error('Auth error:', authError?.message ?? 'No user found');
console.error('Auth header present:', !!authHeader, 'Length:', authHeader?.length ?? 0);
return new Response(JSON.stringify({
error: 'Unauthorized',
code: 401,
message: authError?.message ?? 'Invalid JWT'
}), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const url = new URL(req.url);
const limit = parseInt(url.searchParams.get('limit') || '20');
const offset = parseInt(url.searchParams.get('offset') || '0');
const unreadOnly = url.searchParams.get('unread_only') === 'true';
const includeArchived = url.searchParams.get('include_archived') === 'true';
// Handle different HTTP methods
if (req.method === 'GET') {
// Build query
let query = adminClient
.from('notifications')
.select(`
id,
user_id,
type,
actor_id,
post_id,
comment_id,
metadata,
is_read,
archived_at,
created_at,
actor:actor_id (
id,
handle,
display_name,
avatar_url
),
post:post_id (
id,
body
)
`)
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (!includeArchived) {
query = query.is('archived_at', null);
}
if (unreadOnly) {
query = query.eq('is_read', false);
}
const { data: notifications, error } = await query;
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ notifications }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (req.method === 'PATCH' || req.method === 'POST') {
// Mark notifications as read
const body = await req.json();
const { notification_ids, mark_all_read, archive_ids, archive_all } = body;
const archiveAt = new Date().toISOString();
let didAction = false;
if (archive_all) {
didAction = true;
const { error } = await adminClient
.from('notifications')
.update({ archived_at: archiveAt, is_read: true })
.eq('user_id', user.id)
.is('archived_at', null);
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
}
if (archive_ids && Array.isArray(archive_ids)) {
didAction = true;
const { error } = await adminClient
.from('notifications')
.update({ archived_at: archiveAt, is_read: true })
.in('id', archive_ids)
.eq('user_id', user.id);
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
}
if (mark_all_read) {
didAction = true;
// Mark all notifications as read
const { error } = await adminClient
.from('notifications')
.update({ is_read: true })
.eq('user_id', user.id)
.eq('is_read', false)
.is('archived_at', null);
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (notification_ids && Array.isArray(notification_ids)) {
didAction = true;
// Mark specific notifications as read
const { error } = await adminClient
.from('notifications')
.update({ is_read: true })
.in('id', notification_ids)
.eq('user_id', user.id)
.is('archived_at', null);
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (!didAction) {
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Method not allowed
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,193 @@
import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
import { createSupabaseClient, createServiceClient } from "../_shared/supabase-client.ts";
import { trySignR2Url } from "../_shared/r2_signer.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
const postSelect = `
id,
body,
author_id,
category_id,
tags,
body_format,
background_id,
tone_label,
cis_score,
status,
created_at,
edited_at,
expires_at,
is_edited,
allow_chain,
chain_parent_id,
image_url,
video_url,
thumbnail_url,
duration_ms,
type,
visibility,
pinned_at,
chain_parent:posts (
id,
body,
created_at,
author:profiles!posts_author_id_fkey (
id,
handle,
display_name
)
),
metrics:post_metrics!post_metrics_post_id_fkey (
like_count,
save_count,
view_count
),
author:profiles!posts_author_id_fkey (
id,
handle,
display_name,
trust_state (
user_id,
harmony_score,
tier,
posts_today
)
)
`;
serve(async (req: Request) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(JSON.stringify({ error: "Missing authorization header" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const supabase = createSupabaseClient(authHeader);
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const url = new URL(req.url);
const authorId = url.searchParams.get("author_id");
if (!authorId) {
return new Response(JSON.stringify({ error: "Missing author_id" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const limit = Math.min(parseInt(url.searchParams.get("limit") || "20"), 100);
const offset = parseInt(url.searchParams.get("offset") || "0");
// Use service client to bypass RLS issues
const serviceClient = createServiceClient();
// Check visibility rules manually:
// - User can always see their own posts
// - Public posts are visible to everyone
// - Followers-only posts require accepted follow
// - Private posts only visible to author
const isOwnProfile = authorId === user.id;
// Get author's profile to check privacy settings
const { data: authorProfile } = await serviceClient
.from("profiles")
.select("is_private, is_official")
.eq("id", authorId)
.single();
// Check if viewer follows the author (for followers-only posts)
let hasAcceptedFollow = false;
if (!isOwnProfile) {
const { data: followRow } = await serviceClient
.from("follows")
.select("status")
.eq("follower_id", user.id)
.eq("following_id", authorId)
.eq("status", "accepted")
.maybeSingle();
hasAcceptedFollow = !!followRow;
}
// Build query with appropriate visibility filter
// Note: posts use 'active' status for published posts
let query = serviceClient
.from("posts")
.select(postSelect)
.eq("author_id", authorId)
.eq("status", "active");
// Apply visibility filters based on relationship
if (isOwnProfile) {
// User can see all their own posts (no visibility filter)
} else if (hasAcceptedFollow) {
// Follower can see public and followers-only posts
query = query.in("visibility", ["public", "followers"]);
} else if (authorProfile?.is_official || authorProfile?.is_private === false) {
// Public/official profiles - only public posts
query = query.eq("visibility", "public");
} else {
// Private profile without follow - only public posts
query = query.eq("visibility", "public");
}
const { data: posts, error } = await query
.order("pinned_at", { ascending: false, nullsFirst: false })
.order("created_at", { ascending: false })
.range(offset, offset + limit - 1);
if (error) {
console.error("Profile posts fetch error:", error);
return new Response(JSON.stringify({ error: "Failed to fetch posts" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const signedPosts = await Promise.all(
(posts || []).map(async (post: any) => {
const imageUrl = post.image_url ? await trySignR2Url(post.image_url) : null;
const thumbUrl = post.thumbnail_url ? await trySignR2Url(post.thumbnail_url) : null;
return {
...post,
image_url: imageUrl,
thumbnail_url: thumbUrl,
};
})
);
return new Response(
JSON.stringify({
posts: signedPosts,
pagination: { limit, offset, returned: signedPosts.length },
}),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Unexpected profile-posts error:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,441 @@
/**
* GET /profile/:handle - Get user profile by handle
* GET /profile/me - Get own profile
* PATCH /profile - Update own profile
*
* Design intent:
* - Profiles are public (unless blocked)
* - Shows harmony tier (not score)
* - Minimal public metrics
*/
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createSupabaseClient } from '../_shared/supabase-client.ts';
import { ValidationError } from '../_shared/validation.ts';
import { trySignR2Url } from '../_shared/r2_signer.ts';
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || '*';
const corsHeaders = {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
'Access-Control-Allow-Methods': 'GET, PATCH',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: corsHeaders,
});
}
try {
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const supabase = createSupabaseClient(authHeader);
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const url = new URL(req.url);
const handle = url.searchParams.get('handle');
// GET /profile/me or /profile?handle=username
if (req.method === 'GET') {
let profileQuery;
let isOwnProfile = false;
if (handle) {
// Get profile by handle
profileQuery = supabase
.from('profiles')
.select(
`
id,
handle,
display_name,
bio,
location,
website,
interests,
avatar_url,
cover_url,
origin_country,
is_private,
is_official,
created_at,
trust_state (
tier
)
`
)
.eq('handle', handle)
.single();
} else {
// Get own profile
isOwnProfile = true;
profileQuery = supabase
.from('profiles')
.select(
`
id,
handle,
display_name,
bio,
location,
website,
interests,
avatar_url,
cover_url,
origin_country,
is_private,
is_official,
created_at,
updated_at,
trust_state (
harmony_score,
tier,
posts_today
)
`
)
.eq('id', user.id)
.single();
}
const { data: profile, error: profileError } = await profileQuery;
if (profileError || !profile) {
return new Response(JSON.stringify({ error: 'Profile not found' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Check if viewing own profile
if (profile.id === user.id) {
isOwnProfile = true;
}
// Get privacy settings for this profile
const { data: privacySettings } = await supabase
.from('profile_privacy_settings')
.select('*')
.eq('user_id', profile.id)
.maybeSingle();
const profileVisibility = privacySettings?.profile_visibility || 'public';
// Apply privacy filtering based on viewer relationship
if (!isOwnProfile && profileVisibility !== 'public') {
// Check if viewer is following the profile
const { data: followData } = await supabase
.from('follows')
.select('status')
.eq('follower_id', user.id)
.eq('following_id', profile.id)
.maybeSingle();
const followStatus = followData?.status as string | null;
const isFollowing = followStatus === 'accepted';
let isFollowedBy = false;
if (user.id !== profile.id) {
const { data: reverseFollow } = await supabase
.from('follows')
.select('status')
.eq('follower_id', profile.id)
.eq('following_id', user.id)
.maybeSingle();
isFollowedBy = reverseFollow?.status === 'accepted';
}
const isFriend = isFollowing && isFollowedBy;
// Check privacy visibility
if (profile.is_private || profileVisibility === 'private') {
// Private profiles show minimal info to non-followers
if (!isFollowing) {
return new Response(
JSON.stringify({
profile: {
id: profile.id,
handle: profile.handle ?? 'unknown',
display_name: profile.display_name ?? 'Anonymous',
avatar_url: profile.avatar_url,
created_at: profile.created_at,
trust_state: profile.trust_state,
},
stats: {
posts: 0,
followers: 0,
following: 0,
},
is_following: false,
is_followed_by: isFollowedBy,
is_friend: isFriend,
follow_status: followStatus,
is_private: true,
}),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
} else if (profileVisibility === 'followers') {
// Followers-only profiles hide details from non-followers
if (!isFollowing) {
profile.bio = null;
profile.location = null;
profile.website = null;
profile.interests = null;
}
}
}
// Coalesce null values for older clients
const safeProfile = {
...profile,
handle: profile.handle ?? 'unknown',
display_name: profile.display_name ?? 'Anonymous',
};
if (safeProfile.avatar_url) {
safeProfile.avatar_url = await trySignR2Url(safeProfile.avatar_url);
}
if (safeProfile.cover_url) {
safeProfile.cover_url = await trySignR2Url(safeProfile.cover_url);
}
// Get post count
const { count: postCount } = await supabase
.from('posts')
.select('*', { count: 'exact', head: true })
.eq('author_id', safeProfile.id);
// Get follower/following counts
const { count: followerCount } = await supabase
.from('follows')
.select('*', { count: 'exact', head: true })
.eq('following_id', safeProfile.id)
.eq('status', 'accepted');
const { count: followingCount } = await supabase
.from('follows')
.select('*', { count: 'exact', head: true })
.eq('follower_id', safeProfile.id)
.eq('status', 'accepted');
// Check if current user is following this profile
let isFollowing = false;
let isFollowedBy = false;
let isFriend = false;
let followStatus: string | null = null;
if (user && user.id !== safeProfile.id) {
const { data: followData } = await supabase
.from('follows')
.select('status')
.eq('follower_id', user.id)
.eq('following_id', safeProfile.id)
.maybeSingle();
followStatus = followData?.status as string | null;
isFollowing = followStatus === 'accepted';
const { data: reverseFollow } = await supabase
.from('follows')
.select('status')
.eq('follower_id', safeProfile.id)
.eq('following_id', user.id)
.maybeSingle();
isFollowedBy = reverseFollow?.status === 'accepted';
isFriend = isFollowing && isFollowedBy;
}
const isPrivateForViewer = (profile.is_private ?? false) && !isFollowing && !isOwnProfile;
return new Response(
JSON.stringify({
profile: safeProfile,
stats: {
posts: postCount || 0,
followers: followerCount || 0,
following: followingCount || 0,
},
is_following: isFollowing,
is_followed_by: isFollowedBy,
is_friend: isFriend,
follow_status: followStatus,
is_private: isPrivateForViewer,
}),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// PATCH /profile - Update own profile
if (req.method === 'PATCH') {
const { handle, display_name, bio, location, website, interests, avatar_url, cover_url } = await req.json();
const updates: any = {};
// Handle username changes with 30-day limit
if (handle !== undefined) {
if (handle.trim().length < 3 || handle.length > 20) {
throw new ValidationError('Username must be 3-20 characters', 'handle');
}
if (!/^[a-zA-Z0-9_]+$/.test(handle)) {
throw new ValidationError('Username can only contain letters, numbers, and underscores', 'handle');
}
// Check if handle is already taken
const { data: existingProfile } = await supabase
.from('profiles')
.select('id')
.eq('handle', handle)
.neq('id', user.id)
.maybeSingle();
if (existingProfile) {
throw new ValidationError('Username is already taken', 'handle');
}
// Check 30-day limit
const { data: canChange, error: canChangeError } = await supabase
.rpc('can_change_handle', { p_user_id: user.id });
if (canChangeError || !canChange) {
throw new ValidationError('You can only change your username once every 30 days', 'handle');
}
updates.handle = handle;
}
if (display_name !== undefined) {
if (display_name.trim().length === 0 || display_name.length > 50) {
throw new ValidationError('Display name must be 1-50 characters', 'display_name');
}
updates.display_name = display_name;
}
if (bio !== undefined) {
if (bio && bio.length > 300) {
throw new ValidationError('Bio must be 300 characters or less', 'bio');
}
updates.bio = bio || null;
}
if (location !== undefined) {
if (location && location.length > 100) {
throw new ValidationError('Location must be 100 characters or less', 'location');
}
updates.location = location || null;
}
if (website !== undefined) {
if (website) {
if (website.length > 200) {
throw new ValidationError('Website must be 200 characters or less', 'website');
}
// Validate URL format and scheme
try {
const url = new URL(website.startsWith('http') ? website : `https://${website}`);
if (!['http:', 'https:'].includes(url.protocol)) {
throw new ValidationError('Website must be a valid HTTP or HTTPS URL', 'website');
}
} catch (error) {
throw new ValidationError('Website must be a valid URL', 'website');
}
}
updates.website = website || null;
}
if (interests !== undefined) {
if (Array.isArray(interests)) {
updates.interests = interests;
} else {
throw new ValidationError('Interests must be an array', 'interests');
}
}
if (avatar_url !== undefined) {
updates.avatar_url = avatar_url || null;
}
if (cover_url !== undefined) {
updates.cover_url = cover_url || null;
}
if (Object.keys(updates).length === 0) {
return new Response(JSON.stringify({ error: 'No fields to update' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
updates.updated_at = new Date().toISOString();
const { data: profile, error: updateError } = await supabase
.from('profiles')
.update(updates)
.eq('id', user.id)
.select()
.single();
if (updateError) {
console.error('Error updating profile:', updateError);
return new Response(JSON.stringify({
error: 'Failed to update profile',
details: updateError.message,
code: updateError.code
}), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response(
JSON.stringify({
profile,
message: 'Profile updated',
}),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
} catch (error) {
if (error instanceof ValidationError) {
return new Response(
JSON.stringify({ error: 'Validation error', message: error.message }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
console.error('Unexpected error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,206 @@
/**
* POST /publish-comment
*
* Design intent:
* - Conversation requires consent (mutual follow).
* - Sharp speech is rejected quietly.
* - Comments never affect post reach.
*
* Flow:
* 1. Validate auth and inputs
* 2. Verify mutual follow with post author
* 3. Reject profanity or hostility
* 4. Store comment with tone metadata
* 5. Log audit event
*/
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts';
import { analyzeTone, getRewriteSuggestion } from '../_shared/tone-detection.ts';
import { validateCommentBody, validateUUID, ValidationError } from '../_shared/validation.ts';
interface PublishCommentRequest {
post_id: string;
body: string;
}
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
},
});
}
try {
// 1. Validate auth
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const supabase = createSupabaseClient(authHeader);
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// 2. Parse request
const { post_id, body } = (await req.json()) as PublishCommentRequest;
// 3. Validate inputs
validateUUID(post_id, 'post_id');
validateCommentBody(body);
// 4. Get post author
const { data: post, error: postError } = await supabase
.from('posts')
.select('author_id')
.eq('id', post_id)
.single();
if (postError || !post) {
return new Response(
JSON.stringify({
error: 'Post not found',
message: 'This post does not exist or you cannot see it.',
}),
{
status: 404,
headers: { 'Content-Type': 'application/json' },
}
);
}
// 5. Verify mutual follow
const serviceClient = createServiceClient();
const { data: isMutual, error: followError } = await serviceClient.rpc('is_mutual_follow', {
user_a: user.id,
user_b: post.author_id,
});
if (followError) {
console.error('Error checking mutual follow:', followError);
return new Response(JSON.stringify({ error: 'Failed to verify follow relationship' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
if (!isMutual) {
return new Response(
JSON.stringify({
error: 'Mutual follow required',
message: 'You can only comment on posts from people you mutually follow.',
}),
{
status: 403,
headers: { 'Content-Type': 'application/json' },
}
);
}
// 6. Analyze tone
const analysis = analyzeTone(body);
// 7. Reject hostile or profane content
if (analysis.shouldReject) {
await serviceClient.rpc('log_audit_event', {
p_actor_id: user.id,
p_event_type: 'comment_rejected',
p_payload: {
post_id,
tone: analysis.tone,
cis: analysis.cis,
flags: analysis.flags,
reason: analysis.rejectReason,
},
});
return new Response(
JSON.stringify({
error: 'Comment rejected',
message: analysis.rejectReason,
suggestion: getRewriteSuggestion(analysis),
tone: analysis.tone,
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
// 8. Create comment
const { data: comment, error: commentError } = await supabase
.from('comments')
.insert({
post_id,
author_id: user.id,
body,
tone_label: analysis.tone,
status: 'active',
})
.select()
.single();
if (commentError) {
console.error('Error creating comment:', commentError);
return new Response(JSON.stringify({ error: 'Failed to create comment' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
// 9. Log successful comment
await serviceClient.rpc('log_audit_event', {
p_actor_id: user.id,
p_event_type: 'comment_created',
p_payload: {
comment_id: comment.id,
post_id,
tone: analysis.tone,
cis: analysis.cis,
},
});
// 10. Return comment
return new Response(JSON.stringify({ comment }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
if (error instanceof ValidationError) {
return new Response(
JSON.stringify({
error: 'Validation error',
message: error.message,
field: error.field,
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
console.error('Unexpected error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,629 @@
/**
* POST /publish-post
*
* Features:
* - Hashtag extraction and storage
* - AI tone analysis
* - Rate limiting via trust state
* - Beacon support (location-based alerts)
*/
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts';
import { validatePostBody, validateUUID, ValidationError } from '../_shared/validation.ts';
import { trySignR2Url } from '../_shared/r2_signer.ts';
interface PublishPostRequest {
category_id?: string | null;
body: string;
body_format?: 'plain' | 'markdown';
allow_chain?: boolean;
chain_parent_id?: string | null;
chain_parent_id?: string | null;
image_url?: string | null;
thumbnail_url?: string | null;
ttl_hours?: number | null;
user_warned?: boolean;
// Beacon fields (optional)
is_beacon?: boolean;
beacon_type?: 'police' | 'checkpoint' | 'taskForce' | 'hazard' | 'safety' | 'community';
beacon_lat?: number;
beacon_long?: number;
}
interface AnalysisResult {
flagged: boolean;
category?: 'bigotry' | 'nsfw' | 'violence';
flags: string[];
rejectReason?: string;
}
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || '*';
const CORS_HEADERS = {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
type ModerationStatus = 'approved' | 'flagged_bigotry' | 'flagged_nsfw' | 'rejected';
function getModerationStatus(category?: AnalysisResult['category']): ModerationStatus {
switch (category) {
case 'bigotry':
return 'flagged_bigotry';
case 'nsfw':
return 'flagged_nsfw';
case 'violence':
return 'rejected';
default:
return 'approved';
}
}
/**
* Extract hashtags from post body using regex
* Returns array of lowercase tags without the # prefix
*/
function extractHashtags(body: string): string[] {
const hashtagRegex = /#\w+/g;
const matches = body.match(hashtagRegex);
if (!matches) return [];
// Remove # prefix and lowercase all tags
return [...new Set(matches.map(tag => tag.substring(1).toLowerCase()))];
}
serve(async (req: Request) => {
// CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, { headers: CORS_HEADERS });
}
try {
// 1. Validate auth
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
status: 401,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
});
}
const supabase = createSupabaseClient(authHeader);
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
console.error('Auth error details:', {
error: authError,
errorMessage: authError?.message,
errorStatus: authError?.status,
user: user,
authHeader: authHeader ? 'present' : 'missing',
});
return new Response(JSON.stringify({
error: 'Unauthorized',
details: authError?.message,
}), {
status: 401,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
});
}
const { data: profileRows, error: profileError } = await supabase
.from('profiles')
.select('id')
.eq('id', user.id)
.limit(1);
if (profileError) {
console.error('Error checking profile:', profileError);
return new Response(JSON.stringify({ error: 'Failed to verify profile' }), {
status: 500,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
});
}
if (!profileRows || profileRows.length === 0) {
return new Response(
JSON.stringify({
error: 'Profile not found',
message: 'Please complete your profile before posting.',
}),
{
status: 400,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
}
);
}
// 2. Parse request
const {
category_id,
body,
body_format,
allow_chain,
chain_parent_id,
image_url,
thumbnail_url,
ttl_hours,
user_warned,
is_beacon,
beacon_type,
beacon_lat,
beacon_long,
} = (await req.json()) as PublishPostRequest;
const requestedCategoryId = category_id ?? null;
// 3. Validate inputs
// For beacons, category_id is ignored and replaced by "Beacon Alerts" internally
validatePostBody(body);
if (is_beacon !== true && category_id) {
validateUUID(category_id, 'category_id');
}
if (chain_parent_id) {
validateUUID(chain_parent_id, 'chain_parent_id');
}
let ttlHours: number | null | undefined = undefined;
if (ttl_hours !== undefined && ttl_hours !== null) {
const parsedTtl = Number(ttl_hours);
if (!Number.isFinite(parsedTtl) || !Number.isInteger(parsedTtl) || parsedTtl < 0) {
return new Response(
JSON.stringify({
error: 'Validation error',
message: 'ttl_hours must be a non-negative integer',
}),
{
status: 400,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
}
);
}
ttlHours = parsedTtl;
}
// Validate beacon fields if provided
if (is_beacon === true) {
const latMissing = beacon_lat === undefined || beacon_lat === null || Number.isNaN(beacon_lat);
const longMissing = beacon_long === undefined || beacon_long === null || Number.isNaN(beacon_long);
if (latMissing || longMissing) {
return new Response(
JSON.stringify({
error: 'Validation error',
message: 'beacon_lat and beacon_long are required for beacon posts',
}),
{
status: 400,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
}
);
}
const validBeaconTypes = ['police', 'checkpoint', 'taskForce', 'hazard', 'safety', 'community'];
if (beacon_type && !validBeaconTypes.includes(beacon_type)) {
return new Response(
JSON.stringify({
error: 'Validation error',
message: 'Invalid beacon_type. Must be: police, checkpoint, taskForce, hazard, safety, or community',
}),
{
status: 400,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
}
);
}
}
// 4. Check if user can post (rate limiting via trust state)
const serviceClient = createServiceClient();
const { data: canPostData, error: canPostError } = await serviceClient.rpc('can_post', {
p_user_id: user.id,
});
if (canPostError) {
console.error('Error checking post eligibility:', canPostError);
return new Response(JSON.stringify({ error: 'Failed to check posting eligibility' }), {
status: 500,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
});
}
if (!canPostData) {
const { data: limitData } = await serviceClient.rpc('get_post_rate_limit', {
p_user_id: user.id,
});
return new Response(
JSON.stringify({
error: 'Rate limit reached',
message: `You have reached your posting limit for today (${limitData} posts).`,
suggestion: 'Take a moment. Your influence grows with patience.',
}),
{
status: 429,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
}
);
}
// 5. Validate chain parent (if any)
if (chain_parent_id) {
const { data: parentPost, error: parentError } = await supabase
.from('posts')
.select('id, allow_chain, status')
.eq('id', chain_parent_id)
.single();
if (parentError || !parentPost) {
return new Response(
JSON.stringify({
error: 'Chain unavailable',
message: 'This post is not available for chaining.',
}),
{
status: 400,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
}
);
}
if (!parentPost.allow_chain || parentPost.status !== 'active') {
return new Response(
JSON.stringify({
error: 'Chain unavailable',
message: 'Chaining has been disabled for this post.',
}),
{
status: 400,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
}
);
}
}
// 6. Call tone-check function for AI moderation
let analysis: AnalysisResult = {
flagged: false,
category: undefined,
flags: [],
rejectReason: undefined,
};
try {
const supabaseUrl = Deno.env.get('SUPABASE_URL') || '';
const supabaseKey = Deno.env.get('SUPABASE_ANON_KEY') || '';
console.log('Calling tone-check function...');
const moderationResponse = await fetch(
`${supabaseUrl}/functions/v1/tone-check`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${authHeader}`,
'Content-Type': 'application/json',
'apikey': supabaseKey,
},
body: JSON.stringify({
text: body,
imageUrl: image_url || undefined,
}),
}
);
console.log('tone-check response status:', moderationResponse.status);
if (moderationResponse.ok) {
const data = await moderationResponse.json();
console.log('tone-check response:', JSON.stringify(data));
const flagged = Boolean(data.flagged);
const category = data.category as AnalysisResult['category'] | undefined;
analysis = {
flagged,
category,
flags: data.flags || [],
rejectReason: data.reason,
};
console.log('Analysis result:', JSON.stringify(analysis));
} else {
console.error('tone-check failed:', await moderationResponse.text());
}
} catch (e) {
console.error('Tone check error:', e);
// Fail CLOSED: Reject post if moderation is unavailable
await serviceClient.rpc('log_audit_event', {
p_actor_id: user.id,
p_event_type: 'post_rejected_moderation_error',
p_payload: {
category_id: requestedCategoryId,
error: e.toString(),
},
});
return new Response(
JSON.stringify({
error: 'Moderation unavailable',
message: 'Content moderation is temporarily unavailable. Please try again later.',
}),
{
status: 503,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
}
);
}
// 7. Reject hostile or hateful content
if (analysis.flagged) {
const moderationStatus = getModerationStatus(analysis.category);
if (user_warned === true) {
const { data: profileRow } = await serviceClient
.from('profiles')
.select('strikes')
.eq('id', user.id)
.maybeSingle();
const currentStrikes = typeof profileRow?.strikes === 'number' ? profileRow!.strikes : 0;
await serviceClient
.from('profiles')
.update({ strikes: currentStrikes + 1 })
.eq('id', user.id);
}
await serviceClient.rpc('log_audit_event', {
p_actor_id: user.id,
p_event_type: 'post_rejected',
p_payload: {
category_id: requestedCategoryId,
moderation_category: analysis.category,
flags: analysis.flags,
reason: analysis.rejectReason,
moderation_status: moderationStatus,
user_warned: user_warned === true,
},
});
return new Response(
JSON.stringify({
error: 'Content rejected',
message: analysis.rejectReason || 'This content was rejected by moderation.',
category: analysis.category,
moderation_status: moderationStatus,
}),
{
status: 400,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
}
);
}
// 8. Determine post status based on tone
const status = 'active';
const moderationStatus: ModerationStatus = 'approved';
const toneLabel = 'neutral';
const cisScore = 0.8;
// 9. Extract hashtags from body (skip for beacons - they use description as body)
const tags = is_beacon === true ? [] : extractHashtags(body);
console.log(`Extracted ${tags.length} tags:`, tags);
// 10. Handle beacon category and data
let postCategoryId = requestedCategoryId;
let beaconData: any = null;
if (is_beacon === true) {
// Get or create "Beacon Alerts" category for beacons
const { data: beaconCategory } = await serviceClient
.from('categories')
.select('id')
.eq('name', 'Beacon Alerts')
.single();
if (beaconCategory) {
postCategoryId = beaconCategory.id;
} else {
// Create the beacon category if it doesn't exist
const { data: newCategory } = await serviceClient
.from('categories')
.insert({ name: 'Beacon Alerts', description: 'Community safety and alert posts' })
.select('id')
.single();
if (newCategory) {
postCategoryId = newCategory.id;
}
}
// Get user's trust score for initial confidence
const { data: profile } = await serviceClient
.from('profiles')
.select('trust_state(harmony_score)')
.eq('id', user.id)
.single();
const trustScore = profile?.trust_state?.harmony_score ?? 0.5;
const initialConfidence = 0.5 + (trustScore * 0.3);
// Store beacon data to be included in post
beaconData = {
is_beacon: true,
beacon_type: beacon_type ?? 'community',
location: `SRID=4326;POINT(${beacon_long} ${beacon_lat})`,
confidence_score: Math.min(1.0, Math.max(0.0, initialConfidence)),
is_active_beacon: true,
allow_chain: false, // Beacons don't allow chaining
};
}
// 11. Resolve post expiration
let expiresAt: string | null = null;
if (ttlHours !== undefined) {
if (ttlHours > 0) {
expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000).toISOString();
} else {
expiresAt = null;
}
} else {
const { data: settingsRow, error: settingsError } = await supabase
.from('user_settings')
.select('default_post_ttl')
.eq('user_id', user.id)
.maybeSingle();
if (settingsError) {
console.error('Error fetching user settings:', settingsError);
} else {
const defaultTtl = settingsRow?.default_post_ttl;
if (typeof defaultTtl === 'number' && defaultTtl > 0) {
expiresAt = new Date(Date.now() + defaultTtl * 60 * 60 * 1000).toISOString();
}
}
}
// 12. Create post with tags and beacon data
let postVisibility = 'public';
const { data: privacyRow, error: privacyError } = await supabase
.from('profile_privacy_settings')
.select('posts_visibility')
.eq('user_id', user.id)
.maybeSingle();
if (privacyError) {
console.error('Error fetching privacy settings:', privacyError);
} else if (privacyRow?.posts_visibility) {
postVisibility = privacyRow.posts_visibility;
}
const insertData: any = {
author_id: user.id,
category_id: postCategoryId ?? null,
body,
body_format: body_format ?? 'plain',
tone_label: toneLabel,
cis_score: cisScore,
status,
moderation_status: moderationStatus,
allow_chain: beaconData?.allow_chain ?? (allow_chain ?? true),
chain_parent_id: chain_parent_id ?? null,
image_url: image_url ?? null,
thumbnail_url: thumbnail_url ?? null,
tags: tags,
expires_at: expiresAt,
visibility: postVisibility,
};
// Add beacon fields if this is a beacon
if (beaconData) {
insertData.is_beacon = beaconData.is_beacon;
insertData.beacon_type = beaconData.beacon_type;
insertData.location = beaconData.location;
insertData.confidence_score = beaconData.confidence_score;
insertData.is_active_beacon = beaconData.is_active_beacon;
}
// Use service client for INSERT to bypass RLS issues for private users
// The user authentication has already been validated above
const { data: post, error: postError } = await serviceClient
.from('posts')
.insert(insertData)
.select()
.single();
if (postError) {
console.error('Error creating post:', JSON.stringify({
code: postError.code,
message: postError.message,
details: postError.details,
hint: postError.hint,
user_id: user.id,
}));
return new Response(JSON.stringify({ error: 'Failed to create post', details: postError.message }), {
status: 500,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
});
}
// 13. Log successful post
await serviceClient.rpc('log_audit_event', {
p_actor_id: user.id,
p_event_type: 'post_created',
p_payload: {
post_id: post.id,
category_id: postCategoryId,
tone: toneLabel,
cis: cisScore,
chain_parent_id: chain_parent_id ?? null,
tags_count: tags.length,
is_beacon: is_beacon ?? false,
},
});
// 14. Return post with metadata - FLATTENED STRUCTURE
let message = 'Your post is ready.';
if (toneLabel === 'negative' || toneLabel === 'mixed') {
message = 'This post may have limited reach based on its tone.';
}
// Create a flattened post object that merges post data with location data
const flattenedPost: any = {
...post,
// Add location fields at the top level for beacons
...(beaconData && {
latitude: beacon_lat,
longitude: beacon_long,
}),
};
if (flattenedPost.image_url) {
flattenedPost.image_url = await trySignR2Url(flattenedPost.image_url);
}
if (flattenedPost.thumbnail_url) {
flattenedPost.thumbnail_url = await trySignR2Url(flattenedPost.thumbnail_url);
}
const response: any = {
post: flattenedPost,
tone_analysis: {
tone: toneLabel,
cis: cisScore,
message,
},
tags,
};
return new Response(
JSON.stringify(response),
{
status: 201,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
}
);
} catch (error) {
if (error instanceof ValidationError) {
return new Response(
JSON.stringify({
error: 'Validation error',
message: error.message,
field: error.field,
}),
{
status: 400,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
}
);
}
console.error('Unexpected error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,238 @@
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3';
import { cert, getApps, initializeApp } from 'https://esm.sh/firebase-admin@11.10.1/app?target=deno&bundle';
import { getMessaging } from 'https://esm.sh/firebase-admin@11.10.1/messaging?target=deno&bundle';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
let supabase: ReturnType<typeof createClient> | null = null;
function getSupabase() {
if (supabase) return supabase;
const url = Deno.env.get('SUPABASE_URL') ?? '';
const key = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '';
if (!url || !key) {
throw new Error('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY');
}
supabase = createClient(url, key);
return supabase;
}
function initFirebase() {
if (getApps().length > 0) return;
const raw = Deno.env.get('FIREBASE_SERVICE_ACCOUNT');
if (!raw) {
throw new Error('Missing FIREBASE_SERVICE_ACCOUNT environment variable');
}
const serviceAccount = JSON.parse(raw);
if (serviceAccount.private_key && typeof serviceAccount.private_key === 'string') {
serviceAccount.private_key = serviceAccount.private_key.replace(/\\n/g, '\n');
}
initializeApp({
credential: cert(serviceAccount),
});
}
// Clean up invalid FCM tokens from database
async function cleanupInvalidTokens(
supabaseClient: ReturnType<typeof createClient>,
tokens: string[],
responses: { success: boolean; error?: { code: string } }[]
) {
const invalidTokens: string[] = [];
responses.forEach((response, index) => {
if (!response.success && response.error) {
const errorCode = response.error.code;
// These error codes indicate the token is no longer valid
if (
errorCode === 'messaging/invalid-registration-token' ||
errorCode === 'messaging/registration-token-not-registered' ||
errorCode === 'messaging/invalid-argument'
) {
invalidTokens.push(tokens[index]);
}
}
});
if (invalidTokens.length > 0) {
await supabaseClient
.from('user_fcm_tokens')
.delete()
.in('token', invalidTokens);
console.log(`Cleaned up ${invalidTokens.length} invalid FCM tokens`);
}
return invalidTokens.length;
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
if (req.method === 'GET') {
const vapidKey = Deno.env.get('FIREBASE_WEB_VAPID_KEY') || '';
return new Response(JSON.stringify({ firebase_web_vapid_key: vapidKey }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
try {
initFirebase();
const supabaseClient = getSupabase();
const payload = await req.json();
console.log('Received payload:', JSON.stringify(payload));
// Handle different payload formats:
// - Database webhook: { type: 'INSERT', table: '...', record: {...} }
// - Direct call: { conversation_id: '...', sender_id: '...' }
// - Alternative: { new: {...} }
const record = payload?.record ?? payload?.new ?? payload;
console.log('Extracted record:', JSON.stringify(record));
const conversationId = record?.conversation_id as string | undefined;
const senderId = record?.sender_id as string | undefined;
const messageType = record?.message_type != null
? Number(record.message_type)
: undefined;
console.log(`Processing: conversation=${conversationId}, sender=${senderId}, type=${messageType}`);
if (!conversationId || !senderId) {
console.error('Missing required fields in payload');
return new Response(JSON.stringify({ error: 'Missing conversation_id or sender_id', receivedPayload: payload }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (messageType === 2) {
return new Response(JSON.stringify({ skipped: true, reason: 'command_message' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const { data: conversation, error: conversationError } = await supabaseClient
.from('encrypted_conversations')
.select('participant_a, participant_b')
.eq('id', conversationId)
.single();
if (conversationError || !conversation) {
return new Response(JSON.stringify({ error: 'Conversation not found' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const receiverId =
conversation.participant_a === senderId
? conversation.participant_b
: conversation.participant_a;
if (!receiverId) {
return new Response(JSON.stringify({ error: 'Receiver not resolved' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const { data: senderProfile } = await supabaseClient
.from('profiles')
.select('handle, display_name')
.eq('id', senderId)
.single();
const senderName =
senderProfile?.display_name?.trim() ||
(senderProfile?.handle ? `@${senderProfile.handle}` : 'Someone');
const { data: tokens } = await supabaseClient
.from('user_fcm_tokens')
.select('token')
.eq('user_id', receiverId);
const tokenList = (tokens ?? [])
.map((row) => row.token as string)
.filter((token) => !!token);
if (tokenList.length == 0) {
console.log(`No FCM tokens found for receiver ${receiverId}`);
return new Response(JSON.stringify({ skipped: true, reason: 'no_tokens', receiverId }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
console.log(`Sending to ${tokenList.length} token(s) for receiver ${receiverId}`);
const messaging = getMessaging();
const response = await messaging.sendEachForMulticast({
tokens: tokenList,
notification: {
title: `New Message from ${senderName}`,
body: '🔒 [Encrypted Message]',
},
data: {
conversation_id: conversationId,
type: 'chat',
},
// Android-specific options
android: {
priority: 'high',
notification: {
channelId: 'chat_messages',
priority: 'high',
},
},
// iOS-specific options
apns: {
payload: {
aps: {
sound: 'default',
badge: 1,
contentAvailable: true,
},
},
},
});
// Clean up any invalid tokens
const cleanedUp = await cleanupInvalidTokens(
supabaseClient,
tokenList,
response.responses
);
console.log(`Push notification sent: ${response.successCount} success, ${response.failureCount} failed, ${cleanedUp} tokens cleaned up`);
// Log individual failures for debugging
response.responses.forEach((resp, index) => {
if (!resp.success && resp.error) {
console.error(`Failed to send to token ${index}: ${resp.error.code} - ${resp.error.message}`);
}
});
return new Response(
JSON.stringify({
success: true,
sent: response.successCount,
failed: response.failureCount,
tokensCleanedUp: cleanedUp,
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
} catch (error) {
console.error('Push notification error:', error);
return new Response(JSON.stringify({ error: error.message ?? 'Unknown error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,250 @@
/**
* POST /report
*
* Design intent:
* - Strict reasons only.
* - Reports never auto-remove content.
* - Reporting accuracy affects reporter trust.
*
* Flow:
* 1. Validate auth and inputs
* 2. Ensure target exists and is visible
* 3. Create report record
* 4. Log audit event
* 5. Queue for moderation review
*/
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts';
import { validateReportReason, validateUUID, ValidationError } from '../_shared/validation.ts';
type TargetType = 'post' | 'comment' | 'profile';
interface ReportRequest {
target_type: TargetType;
target_id: string;
reason: string;
}
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
},
});
}
try {
// 1. Validate auth
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const supabase = createSupabaseClient(authHeader);
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// 2. Parse request
const { target_type, target_id, reason } = (await req.json()) as ReportRequest;
// 3. Validate inputs
if (!['post', 'comment', 'profile'].includes(target_type)) {
throw new ValidationError('Invalid target type', 'target_type');
}
validateUUID(target_id, 'target_id');
validateReportReason(reason);
// 4. Verify target exists and is visible to reporter
let targetExists = false;
let targetAuthorId: string | null = null;
if (target_type === 'post') {
const { data: post } = await supabase
.from('posts')
.select('author_id')
.eq('id', target_id)
.single();
if (post) {
targetExists = true;
targetAuthorId = post.author_id;
}
} else if (target_type === 'comment') {
const { data: comment } = await supabase
.from('comments')
.select('author_id')
.eq('id', target_id)
.single();
if (comment) {
targetExists = true;
targetAuthorId = comment.author_id;
}
} else if (target_type === 'profile') {
const { data: profile } = await supabase
.from('profiles')
.select('id')
.eq('id', target_id)
.single();
if (profile) {
targetExists = true;
targetAuthorId = target_id;
}
}
if (!targetExists) {
return new Response(
JSON.stringify({
error: 'Target not found',
message: 'The content you are trying to report does not exist or you cannot see it.',
}),
{
status: 404,
headers: { 'Content-Type': 'application/json' },
}
);
}
// 5. Prevent self-reporting
if (targetAuthorId === user.id) {
return new Response(
JSON.stringify({
error: 'Invalid report',
message: 'You cannot report your own content.',
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
// 6. Check for duplicate reports (constraint will prevent, but give better message)
const { data: existingReport } = await supabase
.from('reports')
.select('id')
.eq('reporter_id', user.id)
.eq('target_type', target_type)
.eq('target_id', target_id)
.single();
if (existingReport) {
return new Response(
JSON.stringify({
error: 'Duplicate report',
message: 'You have already reported this.',
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
// 7. Create report
const { data: report, error: reportError } = await supabase
.from('reports')
.insert({
reporter_id: user.id,
target_type,
target_id,
reason,
status: 'pending',
})
.select()
.single();
if (reportError) {
console.error('Error creating report:', reportError);
return new Response(JSON.stringify({ error: 'Failed to create report' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
// 8. Update reporter's trust counters
const serviceClient = createServiceClient();
await serviceClient.rpc('log_audit_event', {
p_actor_id: user.id,
p_event_type: 'report_filed',
p_payload: {
report_id: report.id,
target_type,
target_id,
target_author_id: targetAuthorId,
},
});
// Increment reports_filed counter
const { error: counterError } = await serviceClient
.from('trust_state')
.update({
counters: supabase.rpc('jsonb_set', {
target: supabase.rpc('counters'),
path: '{reports_filed}',
new_value: supabase.rpc('to_jsonb', [
supabase.rpc('jsonb_extract_path_text', ['counters', 'reports_filed']) + 1,
]),
}),
updated_at: new Date().toISOString(),
})
.eq('user_id', user.id);
if (counterError) {
console.warn('Failed to update report counter:', counterError);
// Continue anyway - report was created
}
// 9. Return success
return new Response(
JSON.stringify({
success: true,
report_id: report.id,
message:
'Report received. All reports are reviewed. False reports may affect your account standing.',
}),
{
status: 201,
headers: { 'Content-Type': 'application/json' },
}
);
} catch (error) {
if (error instanceof ValidationError) {
return new Response(
JSON.stringify({
error: 'Validation error',
message: error.message,
field: error.field,
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
console.error('Unexpected error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,177 @@
/**
* POST /save - Save a post (private bookmark)
* DELETE /save - Remove from saved
*
* Design intent:
* - Saves are private, for personal curation
* - More intentional than appreciation
* - Saves > likes in ranking algorithm
*/
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts';
import { validateUUID, ValidationError } from '../_shared/validation.ts';
interface SaveRequest {
post_id: string;
}
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, DELETE',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
},
});
}
try {
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const supabase = createSupabaseClient(authHeader);
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const { post_id } = (await req.json()) as SaveRequest;
validateUUID(post_id, 'post_id');
// Use admin client to bypass RLS issues
const adminClient = createServiceClient();
// Verify post exists and check visibility
const { data: postRow, error: postError } = await adminClient
.from('posts')
.select('id, visibility, author_id, status')
.eq('id', post_id)
.maybeSingle();
if (postError || !postRow) {
console.error('Post lookup failed:', { post_id, error: postError?.message });
return new Response(
JSON.stringify({ error: 'Post not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
// Check if post is active
if (postRow.status !== 'active') {
return new Response(
JSON.stringify({ error: 'Post is not available' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
// For private posts, verify the user has access (must be author)
if (postRow.visibility === 'private' && postRow.author_id !== user.id) {
return new Response(
JSON.stringify({ error: 'Post not accessible' }),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
// For followers-only posts, verify the user follows the author
if (postRow.visibility === 'followers' && postRow.author_id !== user.id) {
const { data: followRow } = await adminClient
.from('follows')
.select('status')
.eq('follower_id', user.id)
.eq('following_id', postRow.author_id)
.eq('status', 'accepted')
.maybeSingle();
if (!followRow) {
return new Response(
JSON.stringify({ error: 'You must follow this user to save their posts' }),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
}
// Handle unsave (DELETE)
if (req.method === 'DELETE') {
const { error: deleteError } = await adminClient
.from('post_saves')
.delete()
.eq('user_id', user.id)
.eq('post_id', post_id);
if (deleteError) {
console.error('Error removing save:', deleteError);
return new Response(JSON.stringify({ error: 'Failed to remove from saved' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// Handle save (POST)
const { error: saveError } = await adminClient
.from('post_saves')
.insert({
user_id: user.id,
post_id,
});
if (saveError) {
// Already saved (duplicate key)
if (saveError.code === '23505') {
return new Response(
JSON.stringify({ error: 'Post already saved' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
console.error('Error saving post:', saveError);
return new Response(JSON.stringify({ error: 'Failed to save post' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(
JSON.stringify({
success: true,
message: 'Saved. You can find this in your collection.',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
} catch (error) {
if (error instanceof ValidationError) {
return new Response(
JSON.stringify({ error: 'Validation error', message: error.message }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
console.error('Unexpected error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,287 @@
import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
import { createSupabaseClient, createServiceClient } from "../_shared/supabase-client.ts";
import { trySignR2Url } from "../_shared/r2_signer.ts";
interface Profile {
id: string;
handle: string;
display_name: string;
avatar_url: string | null;
harmony_tier: string | null;
}
interface SearchUser {
id: string;
username: string;
display_name: string;
avatar_url: string | null;
harmony_tier: string;
}
interface SearchTag {
tag: string;
count: number;
}
interface SearchPost {
id: string;
body: string;
author_id: string;
author_handle: string;
author_display_name: string;
created_at: string;
image_url: string | null;
visibility?: string;
}
function extractHashtags(text: string): string[] {
const matches = text.match(/#\w+/g) || [];
return [...new Set(matches.map((t) => t.replace("#", "").toLowerCase()))].filter((t) => t.length > 0);
}
function stripHashtags(text: string): string {
return text.replace(/#\w+/g, " ").replace(/\s+/g, " ").trim();
}
serve(async (req: Request) => {
// 1. Handle CORS Preflight
if (req.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
},
});
}
try {
// 2. Auth & Input Parsing
const authHeader = req.headers.get("Authorization");
if (!authHeader) throw new Error("Missing authorization header");
let query: string | null = null;
if (req.method === "POST") {
try {
const body = await req.json();
query = body.query;
} catch { /* ignore parsing error */ }
}
if (!query) {
const url = new URL(req.url);
query = url.searchParams.get("query");
}
// Return empty if no query
if (!query || query.trim().length === 0) {
return new Response(
JSON.stringify({ users: [], tags: [], posts: [] }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
const rawQuery = query.trim();
const isHashtagSearch = rawQuery.startsWith("#");
const cleanTag = isHashtagSearch ? rawQuery.replace("#", "").toLowerCase() : "";
const hashtagFilters = extractHashtags(rawQuery);
const ftsQuery = stripHashtags(rawQuery).replace(/[|&!]/g, " ").trim();
const hasFts = ftsQuery.length > 0;
const safeQuery = rawQuery.toLowerCase().replace(/[,()]/g, "");
console.log("Search query:", rawQuery, "isHashtagSearch:", isHashtagSearch, "cleanTag:", cleanTag);
const supabase = createSupabaseClient(authHeader);
const serviceClient = createServiceClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) throw new Error("Unauthorized");
// 3. Prepare Exclusion Lists (Blocked Users)
const { data: blockedUsers } = await serviceClient
.from("blocks")
.select("blocked_id")
.eq("blocker_id", user.id);
const blockedIds = (blockedUsers?.map((b) => b.blocked_id) || []);
const excludeIds = [...blockedIds, user.id]; // Exclude self from user search
const postExcludeIds = blockedIds; // Allow own posts in search
// 4. Parallel Execution (Fastest Approach)
// Users + tags first so tag matches can inform post search.
const [usersResult, tagsResult] = await Promise.all([
// A. Search Users
(async () => {
let dbQuery = serviceClient
.from("profiles")
.select("id, handle, display_name, avatar_url")
.or(`handle.ilike.%${safeQuery}%,display_name.ilike.%${safeQuery}%`)
.limit(5);
if (excludeIds.length > 0) {
dbQuery = dbQuery.not("id", "in", `(${excludeIds.join(",")})`);
}
return await dbQuery;
})(),
// B. Search Tags (Using the View for performance)
(async () => {
// NOTE: Ensure you have run the SQL to create 'view_searchable_tags'
// If view missing, this returns error, handle gracefully
return await serviceClient
.from("view_searchable_tags")
.select("tag, count")
.ilike("tag", `%${isHashtagSearch ? cleanTag : safeQuery}%`)
.order("count", { ascending: false })
.limit(5);
})(),
]);
const matchedTags = (tagsResult.data || [])
.map((t: any) => String(t.tag).toLowerCase())
.filter((t: string) => t.length > 0);
const tagCandidates = matchedTags.length > 0
? matchedTags
: (isHashtagSearch && cleanTag.length > 0 ? [cleanTag] : []);
// C. Search Posts (tag-first for hashtag queries; hybrid for others)
const postsResult = await (async () => {
const postsMap = new Map<string, any>();
if (isHashtagSearch) {
if (cleanTag.length > 0) {
let exactTagQuery = serviceClient
.from("posts")
.select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)")
.contains("tags", [cleanTag])
.order("created_at", { ascending: false })
.limit(20);
if (excludeIds.length > 0) {
exactTagQuery = exactTagQuery.not("author_id", "in", `(${excludeIds.join(",")})`);
}
const exactTagResult = await exactTagQuery;
(exactTagResult.data || []).forEach((p: any) => postsMap.set(p.id, p));
}
if (tagCandidates.length > 0) {
let tagQuery = serviceClient
.from("posts")
.select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)")
.overlaps("tags", tagCandidates)
.order("created_at", { ascending: false })
.limit(20);
if (postExcludeIds.length > 0) {
tagQuery = tagQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`);
}
const tagResult = await tagQuery;
(tagResult.data || []).forEach((p: any) => postsMap.set(p.id, p));
}
return { data: Array.from(postsMap.values()).slice(0, 20), error: null };
}
if (hasFts) {
let ftsDbQuery = serviceClient
.from("posts")
.select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)")
.order("created_at", { ascending: false })
.limit(20);
if (postExcludeIds.length > 0) {
ftsDbQuery = ftsDbQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`);
}
ftsDbQuery = ftsDbQuery.textSearch("fts", ftsQuery, {
type: "websearch",
config: "english",
});
const ftsResult = await ftsDbQuery;
(ftsResult.data || []).forEach((p: any) => postsMap.set(p.id, p));
}
if (tagCandidates.length > 0) {
let tagOverlapQuery = serviceClient
.from("posts")
.select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)")
.overlaps("tags", tagCandidates)
.order("created_at", { ascending: false })
.limit(20);
if (postExcludeIds.length > 0) {
tagOverlapQuery = tagOverlapQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`);
}
const tagOverlapResult = await tagOverlapQuery;
(tagOverlapResult.data || []).forEach((p: any) => postsMap.set(p.id, p));
}
if (hashtagFilters.length > 0) {
let tagOverlapQuery = serviceClient
.from("posts")
.select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)")
.overlaps("tags", hashtagFilters)
.order("created_at", { ascending: false })
.limit(20);
if (postExcludeIds.length > 0) {
tagOverlapQuery = tagOverlapQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`);
}
const tagOverlapResult = await tagOverlapQuery;
(tagOverlapResult.data || []).forEach((p: any) => postsMap.set(p.id, p));
}
return { data: Array.from(postsMap.values()).slice(0, 20), error: null };
})();
// 5. Process Users (Get Harmony Tiers)
const profiles = usersResult.data || [];
let users: SearchUser[] = [];
if (profiles.length > 0) {
const { data: trustStates } = await serviceClient
.from("trust_state")
.select("user_id, tier")
.in("user_id", profiles.map(p => p.id));
const trustMap = new Map(trustStates?.map(t => [t.user_id, t.tier]) || []);
users = profiles.map((p: any) => ({
id: p.id,
username: p.handle,
display_name: p.display_name || p.handle,
avatar_url: p.avatar_url,
harmony_tier: trustMap.get(p.id) || "new",
}));
}
// 6. Process Tags
const tags: SearchTag[] = (tagsResult.data || []).map((t: any) => ({
tag: t.tag,
count: t.count
}));
// 7. Process Posts
const searchPosts: SearchPost[] = await Promise.all(
(postsResult.data || []).map(async (p: any) => ({
id: p.id,
body: p.body,
author_id: p.author_id,
author_handle: p.profiles?.handle || "unknown",
author_display_name: p.profiles?.display_name || "Unknown User",
created_at: p.created_at,
image_url: p.image_url ? await trySignR2Url(p.image_url) : null,
visibility: p.visibility,
}))
);
// 8. Return Result
return new Response(JSON.stringify({ users, tags, posts: searchPosts }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error: any) {
console.error("Search error:", error);
return new Response(
JSON.stringify({ error: error.message || "Internal server error" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,80 @@
import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
import { createSupabaseClient } from "../_shared/supabase-client.ts";
import { trySignR2Url, transformLegacyMediaUrl } from "../_shared/r2_signer.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(JSON.stringify({ error: "Missing authorization header" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const supabase = createSupabaseClient(authHeader);
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const body = await req.json().catch(() => ({}));
const url = body?.url as string | undefined;
const key = body?.key as string | undefined;
const expiresIn = Number.isFinite(body?.expiresIn) ? Number(body.expiresIn) : 3600;
const target = key || url;
if (!target) {
return new Response(JSON.stringify({ error: "Missing url or key" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// Transform legacy media.sojorn.net URLs to their object key
const transformedTarget = transformLegacyMediaUrl(target) ?? target;
const signedUrl = await trySignR2Url(transformedTarget, expiresIn);
if (!signedUrl) {
return new Response(JSON.stringify({ error: "Failed to sign media URL" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ signedUrl, signed_url: signedUrl }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (error) {
const message = error instanceof Error ? error.message : "Internal server error";
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,195 @@
/**
* POST /signup
*
* User registration and profile creation
* Creates profile + initializes trust_state
*
* Flow:
* 1. User signs up via Supabase Auth (handled by client)
* 2. Client calls this function with profile details
* 3. Create profile record
* 4. Trust state is auto-initialized by trigger
* 5. Return profile data
*/
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createSupabaseClient } from '../_shared/supabase-client.ts';
import { ValidationError } from '../_shared/validation.ts';
interface SignupRequest {
handle: string;
display_name: string;
bio?: string;
}
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net',
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
},
});
}
try {
// 1. Validate auth
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const supabase = createSupabaseClient(authHeader);
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// 2. Parse request
const { handle, display_name, bio } = (await req.json()) as SignupRequest;
// 3. Validate inputs
if (!handle || !handle.match(/^[a-z0-9_]{3,20}$/)) {
throw new ValidationError(
'Handle must be 3-20 characters, lowercase letters, numbers, and underscores only',
'handle'
);
}
if (!display_name || display_name.trim().length === 0 || display_name.length > 50) {
throw new ValidationError('Display name must be 1-50 characters', 'display_name');
}
if (bio && bio.length > 300) {
throw new ValidationError('Bio must be 300 characters or less', 'bio');
}
// 3b. Get origin country from IP geolocation
// Uses ipinfo.io to look up country from client IP address
let originCountry: string | null = null;
const clientIp = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
const ipinfoToken = Deno.env.get('IPINFO_TOKEN');
if (clientIp && ipinfoToken) {
try {
const geoRes = await fetch(`https://api.ipinfo.io/lite/${clientIp}?token=${ipinfoToken}`);
if (geoRes.ok) {
const geoData = await geoRes.json();
// ipinfo.io returns country as ISO 3166-1 alpha-2 code (e.g., 'US', 'GB')
if (geoData.country && /^[A-Z]{2}$/.test(geoData.country)) {
originCountry = geoData.country;
}
}
} catch (geoError) {
// Geolocation is optional - don't fail signup if it errors
console.warn('Geolocation lookup failed:', geoError);
}
}
// 4. Check if profile already exists
const { data: existingProfile } = await supabase
.from('profiles')
.select('id')
.eq('id', user.id)
.single();
if (existingProfile) {
return new Response(
JSON.stringify({
error: 'Profile already exists',
message: 'You have already completed signup',
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
// 5. Create profile (trust_state will be auto-created by trigger)
const { data: profile, error: profileError } = await supabase
.from('profiles')
.insert({
id: user.id,
handle,
display_name,
bio: bio || null,
origin_country: originCountry,
})
.select()
.single();
if (profileError) {
// Check for duplicate handle
if (profileError.code === '23505') {
return new Response(
JSON.stringify({
error: 'Handle taken',
message: 'This handle is already in use. Please choose another.',
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
console.error('Error creating profile:', profileError);
return new Response(JSON.stringify({ error: 'Failed to create profile' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
// 6. Get the auto-created trust state
const { data: trustState } = await supabase
.from('trust_state')
.select('harmony_score, tier')
.eq('user_id', user.id)
.single();
// 7. Return profile data
return new Response(
JSON.stringify({
profile,
trust_state: trustState,
message: 'Welcome to sojorn. Your journey begins quietly.',
}),
{
status: 201,
headers: { 'Content-Type': 'application/json' },
}
);
} catch (error) {
if (error instanceof ValidationError) {
return new Response(
JSON.stringify({
error: 'Validation error',
message: error.message,
field: error.field,
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
console.error('Unexpected error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,202 @@
/// <reference types="https://deno.land/x/deno@v1.28.0/cli/dts/lib.deno.d.ts" />
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
const OPENAI_MODERATION_URL = 'https://api.openai.com/v1/moderations'
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
const corsHeaders = {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
type ModerationCategory = 'bigotry' | 'nsfw' | 'violence'
interface ModerationResult {
flagged: boolean
category?: ModerationCategory
flags: string[]
reason: string
}
// Basic keyword-based fallback (when OpenAI is unavailable)
function basicModeration(text: string): ModerationResult {
const lowerText = text.toLowerCase()
const flags: string[] = []
// Slurs and hate speech patterns (basic detection)
const slurPatterns = [
/\bn+[i1]+g+[aegr]+/i,
/\bf+[a4]+g+[s$o0]+t/i,
/\br+[e3]+t+[a4]+r+d/i,
// Add more patterns as needed
]
for (const pattern of slurPatterns) {
if (pattern.test(text)) {
return {
flagged: true,
category: 'bigotry',
flags: ['hate-speech'],
reason: 'This content contains hate speech or slurs.',
}
}
}
// Targeted profanity/attacks
const attackPatterns = [
/\b(fuck|screw|damn)\s+(you|u|your|ur)\b/i,
/\byou('re| are)\s+(a |an )?(fucking |damn |stupid |idiot|moron|dumb)/i,
/\b(kill|hurt|attack|destroy)\s+(you|yourself)\b/i,
/\byou\s+should\s+(die|kill|hurt)/i,
]
for (const pattern of attackPatterns) {
if (pattern.test(text)) {
flags.push('harassment')
return {
flagged: true,
category: 'bigotry',
flags,
reason: 'This content appears to be harassing or attacking someone.',
}
}
}
// Positive indicators
const positiveWords = ['thank', 'appreciate', 'love', 'support', 'grateful', 'amazing', 'wonderful']
const hasPositive = positiveWords.some(word => lowerText.includes(word))
if (hasPositive) {
return {
flagged: false,
flags: [],
reason: 'Content approved',
}
}
// Default: Allow
return {
flagged: false,
flags: [],
reason: 'Content approved',
}
}
serve(async (req: Request) => {
// Handle CORS
if (req.method === 'OPTIONS') {
return new Response('ok', {
headers: {
...corsHeaders,
'Access-Control-Allow-Methods': 'POST OPTIONS',
},
})
}
try {
const { text, imageUrl } = await req.json() as { text: string; imageUrl?: string }
if (!text || text.trim().length === 0) {
return new Response(
JSON.stringify({ error: 'Text is required' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const openAiKey = Deno.env.get('OPEN_AI')
// Try OpenAI Moderation API first (if key available)
if (openAiKey) {
try {
console.log('Attempting OpenAI moderation check...')
const input: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }> = [
{ type: 'text', text },
]
if (imageUrl) {
input.push({
type: 'image_url',
image_url: { url: imageUrl },
})
}
const moderationResponse = await fetch(OPENAI_MODERATION_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${openAiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
input,
model: 'omni-moderation-latest',
}),
})
if (moderationResponse.ok) {
const moderationData = await moderationResponse.json()
const results = moderationData.results?.[0]
if (results) {
const categories = results.categories || {}
const flags = Object.entries(categories)
.filter(([, value]) => value === true)
.map(([key]) => key)
const isHate = categories.hate || categories['hate/threatening']
const isHarassment = categories.harassment || categories['harassment/threatening']
const isSexual = categories.sexual || categories['sexual/minors']
const isViolence = categories.violence || categories['violence/graphic']
let category: ModerationCategory | undefined
let reason = 'Content approved'
const flagged = Boolean(isHate || isHarassment || isSexual || isViolence)
if (flagged) {
if (isHate || isHarassment) {
category = 'bigotry'
reason = 'Potential hate or harassment detected.'
} else if (isSexual) {
category = 'nsfw'
reason = 'Potential sexual content detected.'
} else if (isViolence) {
category = 'violence'
reason = 'Potential violent content detected.'
}
}
console.log('OpenAI moderation successful:', { flagged, category })
return new Response(JSON.stringify({ flagged, category, flags, reason }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
} else {
const errorText = await moderationResponse.text()
console.error('OpenAI API error:', moderationResponse.status, errorText)
}
} catch (error) {
console.error('OpenAI moderation failed:', error)
}
}
// Fallback to basic keyword moderation
console.log('Using basic keyword moderation (OpenAI unavailable)')
const result = basicModeration(text)
return new Response(JSON.stringify(result), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
} catch (e) {
console.error('Error in tone-check function:', e)
// Fail CLOSED: Reject content when moderation fails
return new Response(
JSON.stringify({
flagged: true,
category: null,
flags: [],
reason: 'Content moderation is temporarily unavailable. Please try again later.',
}),
{ status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
})

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,345 @@
/**
* GET /trending
*
* Design intent:
* - Trending reflects calm resonance, not excitement.
* - Nothing trends forever.
* - Categories do not compete.
*
* Implementation:
* - Category-scoped lists only (no global trending)
* - Eligibility: Positive or Neutral tone, High CIS, Low block/report rate
* - Rank by calm velocity (steady appreciation > spikes)
* - Allow admin editorial override with expiration
*/
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts';
import { rankPosts, type PostForRanking } from '../_shared/ranking.ts';
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
},
});
}
try {
// 1. Validate auth
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const supabase = createSupabaseClient(authHeader);
const serviceClient = createServiceClient();
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// 2. Parse query params
const url = new URL(req.url);
const categorySlug = url.searchParams.get('category');
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50);
if (!categorySlug) {
return new Response(
JSON.stringify({
error: 'Missing category',
message: 'Trending is category-scoped. Provide a category slug.',
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
// 3. Get category ID
const { data: category, error: categoryError } = await supabase
.from('categories')
.select('id, name, slug')
.eq('slug', categorySlug)
.single();
if (categoryError || !category) {
return new Response(JSON.stringify({ error: 'Category not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
// 4. Check for editorial overrides (unexpired)
const { data: overrides } = await supabase
.from('trending_overrides')
.select(
`
post_id,
reason,
posts (
id,
body,
created_at,
tone_label,
cis_score,
allow_chain,
chain_parent_id,
chain_parent:posts!posts_chain_parent_id_fkey (
id,
body,
created_at,
author:profiles!posts_author_id_fkey (
id,
handle,
display_name,
avatar_url
)
),
author:profiles!posts_author_id_fkey (
id,
handle,
display_name,
avatar_url
),
category:categories!posts_category_id_fkey (
id,
slug,
name
),
metrics:post_metrics (
like_count,
save_count
)
)
`
)
.eq('category_id', category.id)
.gt('expires_at', new Date().toISOString())
.order('created_at', { ascending: false });
const overridePosts =
overrides?.map((o: any) => ({
...o.posts,
is_editorial: true,
editorial_reason: o.reason,
})) || [];
// 5. Fetch candidate posts for algorithmic trending
// Eligibility:
// - Positive or Neutral tone only
// - CIS >= 0.8 (high content integrity)
// - Created in last 48 hours (trending is recent)
// - Active status
const twoDaysAgo = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
const { data: posts, error: postsError } = await serviceClient
.from('posts')
.select(
`
id,
body,
created_at,
category_id,
tone_label,
cis_score,
author_id,
author:profiles!posts_author_id_fkey (
id,
handle,
display_name,
avatar_url
),
category:categories!posts_category_id_fkey (
id,
slug,
name
),
metrics:post_metrics (
like_count,
save_count,
view_count
)
`
)
.eq('category_id', category.id)
.in('tone_label', ['positive', 'neutral'])
.gte('cis_score', 0.8)
.gte('created_at', twoDaysAgo)
.eq('status', 'active')
.limit(100); // Candidate pool
if (postsError) {
console.error('Error fetching trending posts:', postsError);
return new Response(JSON.stringify({ error: 'Failed to fetch trending posts' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
// 6. Enrich posts with safety metrics
const authorIds = [...new Set(posts.map((p) => p.author_id))];
const { data: trustStates } = await serviceClient
.from('trust_state')
.select('user_id, harmony_score, tier')
.in('user_id', authorIds);
const trustMap = new Map(trustStates?.map((t) => [t.user_id, t]) || []);
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const { data: recentBlocks } = await serviceClient
.from('blocks')
.select('blocked_id')
.in('blocked_id', authorIds)
.gte('created_at', oneDayAgo);
const blocksMap = new Map<string, number>();
recentBlocks?.forEach((block) => {
blocksMap.set(block.blocked_id, (blocksMap.get(block.blocked_id) || 0) + 1);
});
const postIds = posts.map((p) => p.id);
const { data: reports } = await serviceClient
.from('reports')
.select('target_id, reporter_id')
.eq('target_type', 'post')
.in('target_id', postIds);
const trustedReportMap = new Map<string, number>();
const totalReportMap = new Map<string, number>();
for (const report of reports || []) {
totalReportMap.set(report.target_id, (totalReportMap.get(report.target_id) || 0) + 1);
const reporterTrust = trustMap.get(report.reporter_id);
if (reporterTrust && reporterTrust.harmony_score >= 70) {
trustedReportMap.set(report.target_id, (trustedReportMap.get(report.target_id) || 0) + 1);
}
}
// 7. Filter out posts with safety issues
const safePosts = posts.filter((post) => {
const blocksReceived = blocksMap.get(post.author_id) || 0;
const trustedReports = trustedReportMap.get(post.id) || 0;
// Exclude if:
// - Author received 2+ blocks in 24h
// - Post has any trusted reports
return blocksReceived < 2 && trustedReports === 0;
});
// 8. Transform and rank
const postsForRanking: PostForRanking[] = safePosts.map((post) => {
const authorTrust = trustMap.get(post.author_id);
return {
id: post.id,
created_at: post.created_at,
cis_score: post.cis_score || 0.8,
tone_label: post.tone_label || 'neutral',
save_count: post.metrics?.save_count || 0,
like_count: post.metrics?.like_count || 0,
view_count: post.metrics?.view_count || 0,
author_harmony_score: authorTrust?.harmony_score || 50,
author_tier: authorTrust?.tier || 'new',
blocks_received_24h: blocksMap.get(post.author_id) || 0,
trusted_reports: trustedReportMap.get(post.id) || 0,
total_reports: totalReportMap.get(post.id) || 0,
};
});
const rankedPosts = rankPosts(postsForRanking);
// 9. Take top N algorithmic posts
const topPosts = rankedPosts.slice(0, limit - overridePosts.length);
// 10. Fetch full data for algorithmic posts
const algorithmicIds = topPosts.map((p) => p.id);
const { data: algorithmicPosts } = await supabase
.from('posts')
.select(
`
id,
body,
created_at,
tone_label,
allow_chain,
chain_parent_id,
chain_parent:posts!posts_chain_parent_id_fkey (
id,
body,
created_at,
author:profiles!posts_author_id_fkey (
id,
handle,
display_name,
avatar_url
)
),
author:profiles!posts_author_id_fkey (
id,
handle,
display_name,
avatar_url
),
category:categories!posts_category_id_fkey (
id,
slug,
name
),
metrics:post_metrics (
like_count,
save_count
)
`
)
.in('id', algorithmicIds);
const algorithmicWithFlag = algorithmicPosts?.map((p) => ({ ...p, is_editorial: false })) || [];
// 11. Merge editorial overrides first, then algorithmic
const trendingPosts = [...overridePosts, ...algorithmicWithFlag];
return new Response(
JSON.stringify({
category: {
id: category.id,
slug: category.slug,
name: category.name,
},
posts: trendingPosts,
explanation:
'Trending shows calm resonance: steady saves and appreciation from trusted accounts. Editorial picks are marked. Nothing trends forever.',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
} catch (error) {
console.error('Unexpected error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
});

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"allowJs": true,
"moduleResolution": "node",
"types": ["node"],
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true
},
"include": ["**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1 @@
verify_jwt = false

View file

@ -0,0 +1,150 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
import { AwsClient } from 'https://esm.sh/aws4fetch@1.0.17'
import { trySignR2Url } from "../_shared/r2_signer.ts";
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
try {
// 1. AUTH CHECK
const authHeader = req.headers.get('Authorization')
if (!authHeader) {
return new Response(JSON.stringify({ code: 401, message: 'Missing authorization header' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
// Extract user ID from JWT without full validation
// The JWT is already validated by Supabase's edge runtime
let userId: string
try {
const token = authHeader.replace('Bearer ', '')
const payload = JSON.parse(atob(token.split('.')[1]))
userId = payload.sub
if (!userId) throw new Error('No user ID in token')
console.log('Authenticated user:', userId)
} catch (e) {
console.error('Failed to parse JWT:', e)
return new Response(JSON.stringify({ code: 401, message: 'Invalid JWT' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
// 2. CONFIGURATION
const R2_BUCKET = 'sojorn-media'
const ACCOUNT_ID = (Deno.env.get('R2_ACCOUNT_ID') ?? '').trim()
const ACCESS_KEY = (Deno.env.get('R2_ACCESS_KEY') ?? '').trim()
const SECRET_KEY = (Deno.env.get('R2_SECRET_KEY') ?? '').trim()
if (!ACCOUNT_ID || !ACCESS_KEY || !SECRET_KEY) throw new Error('Missing R2 Secrets')
// 3. PARSE MULTIPART FORM DATA (image + metadata)
const contentType = req.headers.get('content-type') || ''
if (!contentType.includes('multipart/form-data')) {
throw new Error('Request must be multipart/form-data')
}
const formData = await req.formData()
const imageFile = formData.get('image') as File
const fileName = formData.get('fileName') as string
if (!imageFile) {
throw new Error('No image file provided')
}
// Extract and sanitize extension from filename
let extension = 'jpg';
if (fileName) {
const parts = fileName.split('.');
if (parts.length > 1) {
const ext = parts[parts.length - 1].toLowerCase();
// Only allow safe image extensions
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
extension = ext;
}
}
}
const safeFileName = `${crypto.randomUUID()}.${extension}`
const imageContentType = imageFile.type || 'application/octet-stream'
console.log(`Direct upload: fileName=${fileName}, contentType=${imageContentType}, size=${imageFile.size}`)
// 4. INIT R2 CLIENT
const r2 = new AwsClient({
accessKeyId: ACCESS_KEY,
secretAccessKey: SECRET_KEY,
region: 'auto',
service: 's3',
})
// 5. UPLOAD DIRECTLY TO R2 FROM EDGE FUNCTION
const url = `https://${ACCOUNT_ID}.r2.cloudflarestorage.com/${R2_BUCKET}/${safeFileName}`
const imageBytes = await imageFile.arrayBuffer()
const uploadResponse = await r2.fetch(url, {
method: 'PUT',
body: imageBytes,
headers: {
'Content-Type': imageContentType,
'Content-Length': imageBytes.byteLength.toString(),
},
})
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text()
console.error('R2 upload failed:', errorText)
throw new Error(`R2 upload failed: ${uploadResponse.status} ${errorText}`)
}
console.log('Successfully uploaded to R2:', safeFileName)
// 6. RETURN SUCCESS RESPONSE
// Always return a signed URL to avoid public bucket access.
const signedUrl = await trySignR2Url(safeFileName)
if (!signedUrl) {
throw new Error('Failed to generate signed URL')
}
return new Response(
JSON.stringify({
publicUrl: signedUrl,
signedUrl,
signed_url: signedUrl,
fileKey: safeFileName,
fileName: safeFileName,
fileSize: imageFile.size,
contentType: imageContentType,
}),
{
headers: {
...corsHeaders,
'Content-Type': 'application/json',
}
}
)
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
console.error('Upload function error:', errorMessage)
return new Response(
JSON.stringify({
error: errorMessage,
hint: 'Make sure you are logged in and R2 credentials are configured.'
}),
{
status: 400,
headers: {
...corsHeaders,
'Content-Type': 'application/json'
}
}
)
}
})

View file

@ -0,0 +1,161 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
import { AwsClient } from 'https://esm.sh/aws4fetch@1.0.17'
import { trySignR2Url } from "../_shared/r2_signer.ts";
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
try {
// 1. AUTH CHECK
const authHeader = req.headers.get('Authorization')
if (!authHeader) {
return new Response(JSON.stringify({ code: 401, message: 'Missing authorization header' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
// Extract user ID from JWT without full validation
// The JWT is already validated by Supabase's edge runtime
let userId: string
try {
const token = authHeader.replace('Bearer ', '')
const payload = JSON.parse(atob(token.split('.')[1]))
userId = payload.sub
if (!userId) throw new Error('No user ID in token')
console.log('Authenticated user:', userId)
} catch (e) {
console.error('Failed to parse JWT:', e)
return new Response(JSON.stringify({ code: 401, message: 'Invalid JWT' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
// 2. CONFIGURATION
const R2_BUCKET_IMAGES = 'sojorn-media'
const R2_BUCKET_VIDEOS = 'sojorn-videos'
const ACCOUNT_ID = (Deno.env.get('R2_ACCOUNT_ID') ?? '').trim()
const ACCESS_KEY = (Deno.env.get('R2_ACCESS_KEY') ?? '').trim()
const SECRET_KEY = (Deno.env.get('R2_SECRET_KEY') ?? '').trim()
if (!ACCOUNT_ID || !ACCESS_KEY || !SECRET_KEY) throw new Error('Missing R2 Secrets')
// 3. PARSE MULTIPART FORM DATA
const contentType = req.headers.get('content-type') || ''
if (!contentType.includes('multipart/form-data')) {
throw new Error('Request must be multipart/form-data')
}
const formData = await req.formData()
const mediaFile = formData.get('media') as File
const fileName = formData.get('fileName') as string
const mediaType = formData.get('type') as string
if (!mediaFile) {
throw new Error('No media file provided')
}
if (!mediaType || (mediaType !== 'image' && mediaType !== 'video')) {
throw new Error('Invalid or missing type parameter. Must be "image" or "video"')
}
// Extract and sanitize extension from filename
let extension = mediaType === 'image' ? 'jpg' : 'mp4'
if (fileName) {
const parts = fileName.split('.')
if (parts.length > 1) {
const ext = parts[parts.length - 1].toLowerCase()
// Only allow safe extensions
if (mediaType === 'image' && ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
extension = ext
} else if (mediaType === 'video' && ['mp4', 'mov', 'webm'].includes(ext)) {
extension = ext
}
}
}
const safeFileName = `${crypto.randomUUID()}.${extension}`
const mediaContentType = mediaFile.type || (mediaType === 'image' ? 'image/jpeg' : 'video/mp4')
console.log(`Direct upload: type=${mediaType}, fileName=${fileName}, contentType=${mediaContentType}, size=${mediaFile.size}`)
// 4. INIT R2 CLIENT
const r2 = new AwsClient({
accessKeyId: ACCESS_KEY,
secretAccessKey: SECRET_KEY,
region: 'auto',
service: 's3',
})
// 5. UPLOAD DIRECTLY TO R2 FROM EDGE FUNCTION
const bucket = mediaType === 'image' ? R2_BUCKET_IMAGES : R2_BUCKET_VIDEOS
const url = `https://${ACCOUNT_ID}.r2.cloudflarestorage.com/${bucket}/${safeFileName}`
const mediaBytes = await mediaFile.arrayBuffer()
const uploadResponse = await r2.fetch(url, {
method: 'PUT',
body: mediaBytes,
headers: {
'Content-Type': mediaContentType,
'Content-Length': mediaBytes.byteLength.toString(),
},
})
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text()
console.error('R2 upload failed:', errorText)
throw new Error(`R2 upload failed: ${uploadResponse.status} ${errorText}`)
}
console.log('Successfully uploaded to R2:', safeFileName)
// 6. RETURN SUCCESS RESPONSE
// Always return a signed URL to avoid public bucket access.
const signedUrl = await trySignR2Url(safeFileName, bucket)
if (!signedUrl) {
throw new Error('Failed to generate signed URL')
}
return new Response(
JSON.stringify({
publicUrl: signedUrl,
signedUrl,
signed_url: signedUrl,
fileKey: safeFileName,
fileName: safeFileName,
fileSize: mediaFile.size,
contentType: mediaContentType,
type: mediaType,
}),
{
headers: {
...corsHeaders,
'Content-Type': 'application/json',
}
}
)
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
console.error('Upload function error:', errorMessage)
return new Response(
JSON.stringify({
error: errorMessage,
hint: 'Make sure you are logged in and R2 credentials are configured.'
}),
{
status: 400,
headers: {
...corsHeaders,
'Content-Type': 'application/json'
}
}
)
}
})

View file

@ -0,0 +1,17 @@
-- Migration: Add hashtag + full-text search support to posts
-- Created: 2026-01-12
-- Purpose: Make category optional while enabling tag storage and search vectors
-- 1) Make category optional to remove posting friction
alter table posts
alter column category_id drop not null;
-- 2) Store extracted hashtags and full-text search vector
alter table posts
add column if not exists tags text[] default '{}'::text[],
add column if not exists fts tsvector
generated always as (to_tsvector('english', coalesce(body, '')))
stored;
-- 3) Index for fast full-text search lookups
create index if not exists idx_posts_fts on posts using gin (fts);

View file

@ -0,0 +1,11 @@
-- Privacy-first TTL support for posts
-- default_post_ttl is in hours; NULL means keep forever.
alter table if exists user_settings
add column if not exists default_post_ttl integer;
alter table if exists posts
add column if not exists expires_at timestamptz;
create index if not exists posts_expires_at_idx
on posts (expires_at);

View file

@ -0,0 +1,34 @@
-- Security lint remediations
-- 1) Make view_searchable_tags SECURITY INVOKER (avoid definer semantics)
create or replace view view_searchable_tags
with (security_invoker = true) as
select
unnest(tags) as tag,
count(*) as count
from posts
where deleted_at is null
and tags is not null
and array_length(tags, 1) > 0
group by unnest(tags)
order by count desc;
-- 2) Enable RLS on notifications with per-user visibility
alter table if exists notifications enable row level security;
drop policy if exists "Users can view own notifications" on notifications;
create policy "Users can view own notifications" on notifications
for select
using (user_id = auth.uid());
-- Allow inserts/updates/deletes via service role (if your functions need it)
drop policy if exists "Service role manages notifications" on notifications;
create policy "Service role manages notifications" on notifications
for all
using (auth.role() = 'service_role')
with check (auth.role() = 'service_role');
-- 3) Enforce RLS on spatial_ref_sys unconditionally (run as owner/superuser)
alter table spatial_ref_sys enable row level security;
drop policy if exists "Public read spatial_ref_sys" on spatial_ref_sys;
create policy "Public read spatial_ref_sys" on spatial_ref_sys
for select
using (true);

View file

@ -0,0 +1,15 @@
alter table profiles
add column if not exists role text not null default 'user';
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'profiles_role_check'
) then
alter table profiles
add constraint profiles_role_check
check (role in ('user', 'moderator', 'admin', 'banned'));
end if;
end $$;

View file

@ -0,0 +1,18 @@
do $$
begin
if not exists (
select 1
from pg_policies
where schemaname = 'public'
and tablename = 'posts'
and policyname = 'Admins can see everything'
) then
create policy "Admins can see everything"
on posts
for select
to authenticated
using (
(select role from profiles where id = auth.uid()) in ('admin', 'moderator')
);
end if;
end $$;

View file

@ -0,0 +1,59 @@
-- Auto-expire beacons 12 hours after the most recent vouch
create or replace function update_beacon_expires_at(p_beacon_id uuid)
returns void
language plpgsql
security definer
as $$
declare
last_vouch_at timestamptz;
begin
select max(created_at)
into last_vouch_at
from beacon_votes
where beacon_id = p_beacon_id
and vote_type = 'vouch';
update posts
set expires_at = case
when last_vouch_at is null then null
else last_vouch_at + interval '12 hours'
end
where id = p_beacon_id
and is_beacon = true;
end;
$$;
create or replace function handle_beacon_vote_ttl()
returns trigger
language plpgsql
security definer
as $$
declare
target_beacon_id uuid;
begin
target_beacon_id := coalesce(new.beacon_id, old.beacon_id);
if target_beacon_id is not null then
perform update_beacon_expires_at(target_beacon_id);
end if;
return null;
end;
$$;
drop trigger if exists beacon_vote_ttl_trigger on beacon_votes;
create trigger beacon_vote_ttl_trigger
after insert or update or delete on beacon_votes
for each row
execute function handle_beacon_vote_ttl();
-- Backfill existing beacons with vouches
update posts p
set expires_at = v.last_vouch_at + interval '12 hours'
from (
select beacon_id, max(created_at) as last_vouch_at
from beacon_votes
where vote_type = 'vouch'
group by beacon_id
) v
where p.id = v.beacon_id
and p.is_beacon = true;

View file

@ -0,0 +1,18 @@
alter table profiles
add column if not exists strikes integer not null default 0;
alter table posts
add column if not exists moderation_status text not null default 'approved';
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'posts_moderation_status_check'
) then
alter table posts
add constraint posts_moderation_status_check
check (moderation_status in ('approved', 'flagged_bigotry', 'flagged_nsfw', 'rejected'));
end if;
end $$;

View file

@ -0,0 +1,14 @@
-- Add origin_country column to profiles table
-- Stores ISO 3166-1 alpha-2 country code (e.g., 'US', 'GB', 'CA')
-- Captured automatically at signup via ipinfo.io geolocation lookup
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS origin_country TEXT;
-- Add constraint for valid ISO 2-letter code format
ALTER TABLE profiles
ADD CONSTRAINT profiles_origin_country_format
CHECK (origin_country IS NULL OR origin_country ~ '^[A-Z]{2}$');
-- Add comment for documentation
COMMENT ON COLUMN profiles.origin_country IS 'ISO 3166-1 alpha-2 country code captured at signup via ipinfo.io geolocation';

View file

@ -0,0 +1,174 @@
-- Private-by-default follow model + mutuals enforcement
-- 1) Profiles: add privacy/official flags
alter table if exists profiles
add column if not exists is_private boolean not null default true,
add column if not exists is_official boolean not null default false;
-- 2) Follows: add status and constraint
alter table if exists follows
add column if not exists status text not null default 'accepted';
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'follows_status_check'
) then
alter table follows
add constraint follows_status_check
check (status in ('pending', 'accepted'));
end if;
end $$;
-- 3) Request follow function (privacy-aware)
create or replace function request_follow(target_id uuid)
returns text
language plpgsql
security definer
as $$
declare
existing_status text;
target_private boolean;
target_official boolean;
new_status text;
begin
if auth.uid() is null then
raise exception 'Not authenticated';
end if;
select status into existing_status
from follows
where follower_id = auth.uid()
and following_id = target_id;
if existing_status is not null then
return existing_status;
end if;
select is_private, is_official
into target_private, target_official
from profiles
where id = target_id;
if target_private is null then
raise exception 'Target profile not found';
end if;
if target_official or target_private = false then
new_status := 'accepted';
else
new_status := 'pending';
end if;
insert into follows (follower_id, following_id, status)
values (auth.uid(), target_id, new_status);
return new_status;
end;
$$;
-- 4) Mutual follow must be accepted on both sides
create or replace function is_mutual_follow(user_a uuid, user_b uuid)
returns boolean
language plpgsql
security definer
as $$
begin
return exists (
select 1
from follows f1
where f1.follower_id = user_a
and f1.following_id = user_b
and f1.status = 'accepted'
) and exists (
select 1
from follows f2
where f2.follower_id = user_b
and f2.following_id = user_a
and f2.status = 'accepted'
);
end;
$$;
-- 5) Follow request management helpers
create or replace function accept_follow_request(requester_id uuid)
returns void
language plpgsql
security definer
as $$
begin
if auth.uid() is null then
raise exception 'Not authenticated';
end if;
update follows
set status = 'accepted'
where follower_id = requester_id
and following_id = auth.uid();
end;
$$;
create or replace function reject_follow_request(requester_id uuid)
returns void
language plpgsql
security definer
as $$
begin
if auth.uid() is null then
raise exception 'Not authenticated';
end if;
delete from follows
where follower_id = requester_id
and following_id = auth.uid();
end;
$$;
create or replace function get_follow_requests()
returns table (
follower_id uuid,
handle text,
display_name text,
avatar_url text,
requested_at timestamptz
)
language sql
security definer
as $$
select
f.follower_id,
p.handle,
p.display_name,
p.avatar_url,
f.created_at as requested_at
from follows f
join profiles p on p.id = f.follower_id
where f.following_id = auth.uid()
and f.status = 'pending'
order by f.created_at desc;
$$;
-- 6) Posts RLS: allow self, public, or accepted follow
alter table if exists posts enable row level security;
drop policy if exists posts_select_private_model on posts;
create policy posts_select_private_model on posts
for select
using (
auth.uid() = author_id
or exists (
select 1
from profiles p
where p.id = author_id
and p.is_private = false
)
or exists (
select 1
from follows f
where f.follower_id = auth.uid()
and f.following_id = author_id
and f.status = 'accepted'
)
);

View file

@ -0,0 +1,305 @@
-- ============================================================================
-- Secure E2EE Chat System for Mutual Follows
-- ============================================================================
-- This migration creates the infrastructure for end-to-end encrypted messaging
-- using Signal Protocol concepts. Only mutual follows can exchange messages.
-- The server never sees plaintext - only encrypted blobs.
-- ============================================================================
-- ============================================================================
-- 1. Signal Protocol Key Storage
-- ============================================================================
-- Identity and pre-keys for Signal Protocol key exchange
CREATE TABLE IF NOT EXISTS signal_keys (
user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
-- Identity Key (long-term, base64 encoded public key)
identity_key_public TEXT NOT NULL,
-- Signed Pre-Key (medium-term, rotated periodically)
signed_prekey_public TEXT NOT NULL,
signed_prekey_id INTEGER NOT NULL DEFAULT 1,
signed_prekey_signature TEXT NOT NULL,
-- One-Time Pre-Keys (for perfect forward secrecy, consumed on use)
-- Stored as JSONB array: [{"id": 1, "key": "base64..."}, ...]
one_time_prekeys JSONB DEFAULT '[]'::JSONB,
-- Metadata
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index for fast key lookups
CREATE INDEX IF NOT EXISTS idx_signal_keys_user_id ON signal_keys(user_id);
-- ============================================================================
-- 2. Encrypted Conversations Metadata
-- ============================================================================
-- Conversation metadata (no content, just participants and state)
CREATE TABLE IF NOT EXISTS encrypted_conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Participants (always 2 for DM)
participant_a UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
participant_b UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
-- Conversation state
created_at TIMESTAMPTZ DEFAULT NOW(),
last_message_at TIMESTAMPTZ DEFAULT NOW(),
-- Ensure ordered participant storage (smaller UUID first)
-- This prevents duplicate conversations
CONSTRAINT ordered_participants CHECK (participant_a < participant_b),
CONSTRAINT unique_conversation UNIQUE (participant_a, participant_b)
);
-- Indexes for conversation lookups
CREATE INDEX IF NOT EXISTS idx_conversations_participant_a ON encrypted_conversations(participant_a);
CREATE INDEX IF NOT EXISTS idx_conversations_participant_b ON encrypted_conversations(participant_b);
CREATE INDEX IF NOT EXISTS idx_conversations_last_message ON encrypted_conversations(last_message_at DESC);
-- ============================================================================
-- 3. Encrypted Messages
-- ============================================================================
-- Encrypted message storage - server sees ONLY ciphertext
CREATE TABLE IF NOT EXISTS encrypted_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Conversation reference
conversation_id UUID NOT NULL REFERENCES encrypted_conversations(id) ON DELETE CASCADE,
-- Sender (for routing, not content attribution)
sender_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
-- Encrypted payload (what the server stores)
-- This is the Signal Protocol message, completely opaque to server
ciphertext BYTEA NOT NULL,
-- Signal Protocol header (needed for decryption, but reveals nothing)
-- Contains ephemeral key, previous chain length, message number
message_header TEXT NOT NULL,
-- Message type (for protocol handling)
-- 1 = PreKeyWhisperMessage (initial message establishing session)
-- 2 = WhisperMessage (subsequent messages in established session)
message_type INTEGER NOT NULL DEFAULT 2,
-- Delivery metadata
created_at TIMESTAMPTZ DEFAULT NOW(),
delivered_at TIMESTAMPTZ,
read_at TIMESTAMPTZ,
-- Expiration (optional ephemeral messaging)
expires_at TIMESTAMPTZ
);
-- Indexes for message retrieval
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON encrypted_messages(conversation_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_messages_sender ON encrypted_messages(sender_id);
CREATE INDEX IF NOT EXISTS idx_messages_unread ON encrypted_messages(conversation_id, read_at) WHERE read_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_messages_expiring ON encrypted_messages(expires_at) WHERE expires_at IS NOT NULL;
-- ============================================================================
-- 4. Helper Functions
-- ============================================================================
-- Check if two users have a mutual follow relationship
CREATE OR REPLACE FUNCTION is_mutual_follow(user_a UUID, user_b UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM follows f1
WHERE f1.follower_id = user_a
AND f1.following_id = user_b
) AND EXISTS (
SELECT 1 FROM follows f2
WHERE f2.follower_id = user_b
AND f2.following_id = user_a
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Get or create a conversation between two mutual follows
CREATE OR REPLACE FUNCTION get_or_create_conversation(user_a UUID, user_b UUID)
RETURNS UUID AS $$
DECLARE
conv_id UUID;
ordered_a UUID;
ordered_b UUID;
BEGIN
-- Verify mutual follow
IF NOT is_mutual_follow(user_a, user_b) THEN
RAISE EXCEPTION 'Users must have mutual follow to start conversation';
END IF;
-- Order participants for consistent storage
IF user_a < user_b THEN
ordered_a := user_a;
ordered_b := user_b;
ELSE
ordered_a := user_b;
ordered_b := user_a;
END IF;
-- Try to get existing conversation
SELECT id INTO conv_id
FROM encrypted_conversations
WHERE participant_a = ordered_a AND participant_b = ordered_b;
-- Create if doesn't exist
IF conv_id IS NULL THEN
INSERT INTO encrypted_conversations (participant_a, participant_b)
VALUES (ordered_a, ordered_b)
RETURNING id INTO conv_id;
END IF;
RETURN conv_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Consume a one-time pre-key (returns and removes it atomically)
CREATE OR REPLACE FUNCTION consume_one_time_prekey(target_user_id UUID)
RETURNS JSONB AS $$
DECLARE
prekey JSONB;
remaining JSONB;
BEGIN
-- Get the first prekey
SELECT one_time_prekeys->0 INTO prekey
FROM signal_keys
WHERE user_id = target_user_id
AND jsonb_array_length(one_time_prekeys) > 0
FOR UPDATE;
IF prekey IS NULL THEN
RETURN NULL;
END IF;
-- Remove it from the array
UPDATE signal_keys
SET one_time_prekeys = one_time_prekeys - 0,
updated_at = NOW()
WHERE user_id = target_user_id;
RETURN prekey;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================
-- 5. Row Level Security Policies
-- ============================================================================
-- Enable RLS on all tables
ALTER TABLE signal_keys ENABLE ROW LEVEL SECURITY;
ALTER TABLE encrypted_conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE encrypted_messages ENABLE ROW LEVEL SECURITY;
-- Signal Keys: Users can only manage their own keys
CREATE POLICY signal_keys_select ON signal_keys
FOR SELECT USING (true); -- Anyone can read public keys
CREATE POLICY signal_keys_insert ON signal_keys
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY signal_keys_update ON signal_keys
FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY signal_keys_delete ON signal_keys
FOR DELETE USING (auth.uid() = user_id);
-- Conversations: Only participants can see their conversations
CREATE POLICY conversations_select ON encrypted_conversations
FOR SELECT USING (
auth.uid() = participant_a OR auth.uid() = participant_b
);
-- Conversations are created via the get_or_create_conversation function
-- which enforces mutual follow, so we allow insert if user is a participant
CREATE POLICY conversations_insert ON encrypted_conversations
FOR INSERT WITH CHECK (
(auth.uid() = participant_a OR auth.uid() = participant_b)
AND is_mutual_follow(participant_a, participant_b)
);
-- Messages: Only conversation participants can see/send messages
CREATE POLICY messages_select ON encrypted_messages
FOR SELECT USING (
EXISTS (
SELECT 1 FROM encrypted_conversations c
WHERE c.id = conversation_id
AND (c.participant_a = auth.uid() OR c.participant_b = auth.uid())
)
);
-- Critical: Messages can only be inserted by sender who is in a mutual follow
CREATE POLICY messages_insert ON encrypted_messages
FOR INSERT WITH CHECK (
auth.uid() = sender_id
AND EXISTS (
SELECT 1 FROM encrypted_conversations c
WHERE c.id = conversation_id
AND (c.participant_a = auth.uid() OR c.participant_b = auth.uid())
AND is_mutual_follow(c.participant_a, c.participant_b)
)
);
-- Users can update their own sent messages (for read receipts on received messages)
CREATE POLICY messages_update ON encrypted_messages
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM encrypted_conversations c
WHERE c.id = conversation_id
AND (c.participant_a = auth.uid() OR c.participant_b = auth.uid())
)
);
-- ============================================================================
-- 6. Triggers for Metadata Updates
-- ============================================================================
-- Update conversation last_message_at when new message is inserted
CREATE OR REPLACE FUNCTION update_conversation_timestamp()
RETURNS TRIGGER AS $$
BEGIN
UPDATE encrypted_conversations
SET last_message_at = NEW.created_at
WHERE id = NEW.conversation_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_update_conversation_timestamp
AFTER INSERT ON encrypted_messages
FOR EACH ROW
EXECUTE FUNCTION update_conversation_timestamp();
-- Auto-expire old messages (for ephemeral messaging)
CREATE OR REPLACE FUNCTION cleanup_expired_messages()
RETURNS void AS $$
BEGIN
DELETE FROM encrypted_messages
WHERE expires_at IS NOT NULL AND expires_at < NOW();
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- 7. Realtime Subscriptions
-- ============================================================================
-- Enable realtime for messages (participants will subscribe to their conversations)
ALTER PUBLICATION supabase_realtime ADD TABLE encrypted_messages;
-- ============================================================================
-- 8. Comments for Documentation
-- ============================================================================
COMMENT ON TABLE signal_keys IS 'Public cryptographic keys for Signal Protocol key exchange. Private keys stored only on device.';
COMMENT ON TABLE encrypted_conversations IS 'Metadata for E2EE conversations. No message content stored here.';
COMMENT ON TABLE encrypted_messages IS 'Encrypted message blobs. Server cannot decrypt - only route.';
COMMENT ON FUNCTION is_mutual_follow IS 'Returns true if both users follow each other.';
COMMENT ON FUNCTION get_or_create_conversation IS 'Creates or retrieves a conversation, enforcing mutual follow requirement.';
COMMENT ON FUNCTION consume_one_time_prekey IS 'Atomically retrieves and removes a one-time pre-key for forward secrecy.';

View file

@ -0,0 +1,26 @@
-- Fix notifications type check to allow new follow-related types
do $$
begin
if exists (
select 1
from pg_constraint
where conname = 'notifications_type_check'
) then
alter table notifications
drop constraint notifications_type_check;
end if;
end $$;
alter table notifications
add constraint notifications_type_check
check (type in (
'appreciate',
'chain',
'follow',
'comment',
'mention',
'follow_request',
'new_follower',
'request_accepted'
));

View file

@ -0,0 +1,65 @@
-- Fix RLS policies for secure chat messages to ensure participants can access them.
-- 1. Ensure RLS is enabled on the encrypted_messages table.
ALTER TABLE public.encrypted_messages ENABLE ROW LEVEL SECURITY;
-- 2. Policy for SELECTing messages.
-- Allows a user to read messages in conversations they are a participant in.
DROP POLICY IF EXISTS "Allow participants to read messages" ON public.encrypted_messages;
CREATE POLICY "Allow participants to read messages"
ON public.encrypted_messages FOR SELECT USING (
exists (
select 1
from public.encrypted_conversations
where id = encrypted_messages.conversation_id
and (
participant_a = auth.uid() or
participant_b = auth.uid()
)
)
);
-- 3. Policy for INSERTing new messages.
-- Allows a user to insert a message if they are the sender and a participant
-- in the conversation.
DROP POLICY IF EXISTS "Allow participants to send messages" ON public.encrypted_messages;
CREATE POLICY "Allow participants to send messages"
ON public.encrypted_messages FOR INSERT WITH CHECK (
sender_id = auth.uid() AND
exists (
select 1
from public.encrypted_conversations
where id = encrypted_messages.conversation_id
and (
participant_a = auth.uid() or
participant_b = auth.uid()
)
)
);
-- 4. Policy for UPDATing messages (e.g., marking as read).
-- Allows a participant to update a message in their conversation.
-- The client-side code should enforce that only the recipient can mark as read.
DROP POLICY IF EXISTS "Allow participants to update messages" ON public.encrypted_messages;
CREATE POLICY "Allow participants to update messages"
ON public.encrypted_messages FOR UPDATE USING (
exists (
select 1
from public.encrypted_conversations
where id = encrypted_messages.conversation_id
and (
participant_a = auth.uid() or
participant_b = auth.uid()
)
)
) WITH CHECK (
exists (
select 1
from public.encrypted_conversations
where id = encrypted_messages.conversation_id
and (
participant_a = auth.uid() or
participant_b = auth.uid()
)
)
);

View file

@ -0,0 +1,55 @@
-- Follow guardrails: prevent self-follow and block-based follows
create or replace function request_follow(target_id uuid)
returns text
language plpgsql
security definer
as $$
declare
existing_status text;
target_private boolean;
target_official boolean;
new_status text;
begin
if auth.uid() is null then
raise exception 'Not authenticated';
end if;
if target_id is null then
raise exception 'Target profile not found';
end if;
if auth.uid() = target_id then
raise exception 'Cannot follow yourself';
end if;
select status into existing_status
from follows
where follower_id = auth.uid()
and following_id = target_id;
if existing_status is not null then
return existing_status;
end if;
select is_private, is_official
into target_private, target_official
from profiles
where id = target_id;
if target_private is null then
raise exception 'Target profile not found';
end if;
if target_official or target_private = false then
new_status := 'accepted';
else
new_status := 'pending';
end if;
insert into follows (follower_id, following_id, status)
values (auth.uid(), target_id, new_status);
return new_status;
end;
$$;

View file

@ -0,0 +1,67 @@
-- Follow notifications: trigger + metadata
alter table if exists notifications
add column if not exists metadata jsonb not null default '{}'::jsonb;
do $$
begin
alter type notification_type add value if not exists 'follow_request';
alter type notification_type add value if not exists 'new_follower';
alter type notification_type add value if not exists 'request_accepted';
end $$;
create or replace function handle_follow_notification()
returns trigger
language plpgsql
as $$
begin
if tg_op = 'INSERT' then
if new.status = 'pending' then
insert into notifications (user_id, type, actor_id, metadata)
values (
new.following_id,
'follow_request',
new.follower_id,
jsonb_build_object(
'follower_id', new.follower_id,
'following_id', new.following_id,
'status', new.status
)
);
elsif new.status = 'accepted' then
insert into notifications (user_id, type, actor_id, metadata)
values (
new.following_id,
'new_follower',
new.follower_id,
jsonb_build_object(
'follower_id', new.follower_id,
'following_id', new.following_id,
'status', new.status
)
);
end if;
elsif tg_op = 'UPDATE' then
if old.status = 'pending' and new.status = 'accepted' then
insert into notifications (user_id, type, actor_id, metadata)
values (
new.follower_id,
'request_accepted',
new.following_id,
jsonb_build_object(
'follower_id', new.follower_id,
'following_id', new.following_id,
'status', new.status
)
);
end if;
end if;
return new;
end;
$$;
drop trigger if exists follow_notification_trigger on follows;
create trigger follow_notification_trigger
after insert or update on follows
for each row execute function handle_follow_notification();

View file

@ -0,0 +1,8 @@
-- Allow one pinned post per author for profile feeds
alter table if exists posts
add column if not exists pinned_at timestamptz;
create unique index if not exists posts_author_pinned_unique
on posts (author_id)
where pinned_at is not null;

View file

@ -0,0 +1,39 @@
-- Post-level visibility controls
alter table if exists posts
add column if not exists visibility text not null default 'public';
update posts
set visibility = 'public'
where visibility is null;
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'posts_visibility_check'
) then
alter table posts
add constraint posts_visibility_check
check (visibility in ('public', 'followers', 'private'));
end if;
end $$;
drop policy if exists posts_select_private_model on posts;
create policy posts_select_private_model on posts
for select
using (
auth.uid() = author_id
or visibility = 'public'
or (
visibility = 'followers'
and exists (
select 1
from follows f
where f.follower_id = auth.uid()
and f.following_id = author_id
and f.status = 'accepted'
)
)
);

View file

@ -0,0 +1,109 @@
-- ============================================================================
-- Signal Protocol Schema Updates
-- ============================================================================
-- Updates to support proper Signal Protocol implementation with separate
-- one_time_prekeys table and profiles identity key storage.
-- ============================================================================
-- ============================================================================
-- 1. Update profiles table to store identity key and registration ID
-- ============================================================================
-- Add Signal Protocol identity key and registration ID to profiles
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS identity_key TEXT,
ADD COLUMN IF NOT EXISTS registration_id INTEGER;
-- ============================================================================
-- 2. Create separate one_time_prekeys table
-- ============================================================================
-- Separate table for one-time pre-keys (consumed on use)
CREATE TABLE IF NOT EXISTS one_time_prekeys (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
key_id INTEGER NOT NULL,
public_key TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
-- Ensure unique key_id per user
UNIQUE(user_id, key_id)
);
-- Index for efficient key consumption
CREATE INDEX IF NOT EXISTS idx_one_time_prekeys_user_id ON one_time_prekeys(user_id);
-- ============================================================================
-- 3. Update signal_keys table structure
-- ============================================================================
-- Remove one_time_prekeys from signal_keys (now separate table)
ALTER TABLE signal_keys
DROP COLUMN IF EXISTS one_time_prekeys;
-- Add registration_id to signal_keys if not already present
ALTER TABLE signal_keys
ADD COLUMN IF NOT EXISTS registration_id INTEGER;
-- ============================================================================
-- 4. Update consume_one_time_prekey function
-- ============================================================================
-- Update the function to work with the separate table
CREATE OR REPLACE FUNCTION consume_one_time_prekey(target_user_id UUID)
RETURNS TABLE(key_id INTEGER, public_key TEXT) AS $$
BEGIN
RETURN QUERY
DELETE FROM one_time_prekeys
WHERE user_id = target_user_id
ORDER BY created_at ASC
LIMIT 1
RETURNING one_time_prekeys.key_id, one_time_prekeys.public_key;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================
-- 5. Update RLS policies for one_time_prekeys
-- ============================================================================
-- Enable RLS
ALTER TABLE one_time_prekeys ENABLE ROW LEVEL SECURITY;
-- Users can read their own pre-keys (for management)
CREATE POLICY one_time_prekeys_select_own ON one_time_prekeys
FOR SELECT USING (auth.uid() = user_id);
-- Users can insert their own pre-keys
CREATE POLICY one_time_prekeys_insert_own ON one_time_prekeys
FOR INSERT WITH CHECK (auth.uid() = user_id);
-- Users can delete their own pre-keys (when consumed)
CREATE POLICY one_time_prekeys_delete_own ON one_time_prekeys
FOR DELETE USING (auth.uid() = user_id);
-- ============================================================================
-- 6. Migration helper: Move existing one_time_prekeys to new table
-- ============================================================================
-- Insert existing one_time_prekeys from signal_keys into the new table
INSERT INTO one_time_prekeys (user_id, key_id, public_key)
SELECT
sk.user_id,
(prekey->>'id')::INTEGER,
prekey->>'key'
FROM signal_keys sk,
jsonb_array_elements(sk.one_time_prekeys) AS prekey
ON CONFLICT (user_id, key_id) DO NOTHING;
-- Remove the old column after migration
-- (Commented out to prevent accidental data loss - run manually after verification)
-- ALTER TABLE signal_keys DROP COLUMN IF EXISTS one_time_prekeys;
-- ============================================================================
-- 7. Comments for documentation
-- ============================================================================
COMMENT ON TABLE one_time_prekeys IS 'One-time pre-keys for Signal Protocol. Each key is consumed after first use.';
COMMENT ON COLUMN profiles.identity_key IS 'Signal Protocol identity key public part (base64 encoded)';
COMMENT ON COLUMN profiles.registration_id IS 'Signal Protocol registration ID for this user';
COMMENT ON FUNCTION consume_one_time_prekey IS 'Atomically consumes and returns the oldest one-time pre-key for a user';

View file

@ -0,0 +1,111 @@
-- ============================================================================
-- E2EE Policy Fix Migration
-- ============================================================================
-- This migration safely recreates policies that may have failed on initial run.
-- Uses DROP IF EXISTS before CREATE to be idempotent.
-- ============================================================================
-- ============================================================================
-- 1. Fix e2ee_session_commands policies
-- ============================================================================
DROP POLICY IF EXISTS session_commands_select_own ON e2ee_session_commands;
DROP POLICY IF EXISTS session_commands_insert_own ON e2ee_session_commands;
DROP POLICY IF EXISTS session_commands_update_own ON e2ee_session_commands;
CREATE POLICY session_commands_select_own ON e2ee_session_commands
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY session_commands_insert_own ON e2ee_session_commands
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY session_commands_update_own ON e2ee_session_commands
FOR UPDATE USING (auth.uid() = user_id);
-- ============================================================================
-- 2. Fix e2ee_session_events policies
-- ============================================================================
DROP POLICY IF EXISTS session_events_select_own ON e2ee_session_events;
DROP POLICY IF EXISTS session_events_insert_own ON e2ee_session_events;
DROP POLICY IF EXISTS session_events_update_own ON e2ee_session_events;
CREATE POLICY session_events_select_own ON e2ee_session_events
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY session_events_insert_own ON e2ee_session_events
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY session_events_update_own ON e2ee_session_events
FOR UPDATE USING (auth.uid() = user_id);
-- ============================================================================
-- 3. Fix e2ee_decryption_failures policies
-- ============================================================================
DROP POLICY IF EXISTS decryption_failures_select_own ON e2ee_decryption_failures;
DROP POLICY IF EXISTS decryption_failures_insert_own ON e2ee_decryption_failures;
DROP POLICY IF EXISTS decryption_failures_update_own ON e2ee_decryption_failures;
CREATE POLICY decryption_failures_select_own ON e2ee_decryption_failures
FOR SELECT USING (auth.uid() = recipient_id);
CREATE POLICY decryption_failures_insert_own ON e2ee_decryption_failures
FOR INSERT WITH CHECK (auth.uid() = recipient_id);
CREATE POLICY decryption_failures_update_own ON e2ee_decryption_failures
FOR UPDATE USING (auth.uid() = recipient_id);
-- ============================================================================
-- 4. Fix e2ee_session_state policies
-- ============================================================================
DROP POLICY IF EXISTS session_state_select_own ON e2ee_session_state;
CREATE POLICY session_state_select_own ON e2ee_session_state
FOR SELECT USING (auth.uid() = user_id OR auth.uid() = peer_id);
-- ============================================================================
-- 5. Safely add tables to realtime publication (ignore if already added)
-- ============================================================================
DO $$
BEGIN
-- Add e2ee_session_events if not already in publication
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
AND tablename = 'e2ee_session_events'
) THEN
ALTER PUBLICATION supabase_realtime ADD TABLE e2ee_session_events;
END IF;
-- Add e2ee_session_commands if not already in publication
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
AND tablename = 'e2ee_session_commands'
) THEN
ALTER PUBLICATION supabase_realtime ADD TABLE e2ee_session_commands;
END IF;
-- Add e2ee_session_state if not already in publication
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
AND tablename = 'e2ee_session_state'
) THEN
ALTER PUBLICATION supabase_realtime ADD TABLE e2ee_session_state;
END IF;
END $$;
-- ============================================================================
-- 6. Ensure event type constraint includes all types
-- ============================================================================
ALTER TABLE e2ee_session_events
DROP CONSTRAINT IF EXISTS e2ee_session_events_event_type_check;
ALTER TABLE e2ee_session_events
ADD CONSTRAINT e2ee_session_events_event_type_check
CHECK (event_type IN ('session_reset', 'conversation_cleanup', 'key_refresh', 'decryption_failure', 'session_mismatch', 'session_established'));

View file

@ -0,0 +1,232 @@
-- ============================================================================
-- E2EE Session Manager Tables
-- ============================================================================
-- Tables to support the E2EE session management edge function
-- These tables handle session commands, events, and cleanup operations
-- ============================================================================
-- ============================================================================
-- 1. Session Commands Table
-- ============================================================================
-- Table to store session management commands
CREATE TABLE IF NOT EXISTS e2ee_session_commands (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Command details
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
recipient_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
conversation_id UUID REFERENCES encrypted_conversations(id) ON DELETE CASCADE,
command_type TEXT NOT NULL CHECK (command_type IN ('session_reset', 'conversation_cleanup', 'key_refresh')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'completed', 'failed')),
-- Metadata
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ,
error_message TEXT
);
-- Indexes for efficient command processing
CREATE INDEX IF NOT EXISTS idx_session_commands_user ON e2ee_session_commands(user_id);
CREATE INDEX IF NOT EXISTS idx_session_commands_status ON e2ee_session_commands(status);
CREATE INDEX IF NOT EXISTS idx_session_commands_type ON e2ee_session_commands(command_type);
CREATE INDEX IF NOT EXISTS idx_session_commands_conversation ON e2ee_session_commands(conversation_id);
-- ============================================================================
-- 2. Session Events Table
-- ============================================================================
-- Table to store session management events for realtime notifications
CREATE TABLE IF NOT EXISTS e2ee_session_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Event details
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
event_type TEXT NOT NULL CHECK (event_type IN ('session_reset', 'conversation_cleanup', 'key_refresh', 'decryption_failure')),
recipient_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
conversation_id UUID REFERENCES encrypted_conversations(id) ON DELETE CASCADE,
-- Additional context
message_id UUID REFERENCES encrypted_messages(id) ON DELETE CASCADE,
error_details JSONB,
-- Metadata
timestamp TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ,
processed_by TEXT
);
-- Indexes for efficient event processing
CREATE INDEX IF NOT EXISTS idx_session_events_user ON e2ee_session_events(user_id);
CREATE INDEX IF NOT EXISTS idx_session_events_type ON e2ee_session_events(event_type);
CREATE INDEX IF NOT EXISTS idx_session_events_timestamp ON e2ee_session_events(timestamp DESC);
-- ============================================================================
-- 3. Decryption Failure Logs
-- ============================================================================
-- Table to log decryption failures for debugging and recovery
CREATE TABLE IF NOT EXISTS e2ee_decryption_failures (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Failure details
message_id UUID NOT NULL REFERENCES encrypted_messages(id) ON DELETE CASCADE,
sender_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
recipient_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
conversation_id UUID NOT NULL REFERENCES encrypted_conversations(id) ON DELETE CASCADE,
-- Error information
error_type TEXT NOT NULL,
error_message TEXT,
stack_trace TEXT,
ciphertext_length INTEGER,
message_type INTEGER,
-- Context for debugging
session_key_exists BOOLEAN,
ephemeral_key_in_header BOOLEAN,
header_data JSONB,
-- Recovery status
recovery_attempted BOOLEAN DEFAULT FALSE,
recovery_success BOOLEAN,
recovery_method TEXT,
-- Metadata
created_at TIMESTAMPTZ DEFAULT NOW(),
last_updated TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes for failure analysis
CREATE INDEX IF NOT EXISTS idx_decryption_failures_message ON e2ee_decryption_failures(message_id);
CREATE INDEX IF NOT EXISTS idx_decryption_failures_conversation ON e2ee_decryption_failures(conversation_id);
CREATE INDEX IF NOT EXISTS idx_decryption_failures_recipient ON e2ee_decryption_failures(recipient_id);
CREATE INDEX IF NOT EXISTS idx_decryption_failures_timestamp ON e2ee_decryption_failures(created_at DESC);
-- ============================================================================
-- 4. RLS Policies
-- ============================================================================
-- Enable RLS on all new tables
ALTER TABLE e2ee_session_commands ENABLE ROW LEVEL SECURITY;
ALTER TABLE e2ee_session_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE e2ee_decryption_failures ENABLE ROW LEVEL SECURITY;
-- Session Commands: Users can only see their own commands
CREATE POLICY session_commands_select_own ON e2ee_session_commands
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY session_commands_insert_own ON e2ee_session_commands
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY session_commands_update_own ON e2ee_session_commands
FOR UPDATE USING (auth.uid() = user_id);
-- Session Events: Users can only see their own events
CREATE POLICY session_events_select_own ON e2ee_session_events
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY session_events_insert_own ON e2ee_session_events
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY session_events_update_own ON e2ee_session_events
FOR UPDATE USING (auth.uid() = user_id);
-- Decryption Failures: Users can only see failures for their own messages
CREATE POLICY decryption_failures_select_own ON e2ee_decryption_failures
FOR SELECT USING (auth.uid() = recipient_id);
CREATE POLICY decryption_failures_insert_own ON e2ee_decryption_failures
FOR INSERT WITH CHECK (auth.uid() = recipient_id);
CREATE POLICY decryption_failures_update_own ON e2ee_decryption_failures
FOR UPDATE USING (auth.uid() = recipient_id);
-- ============================================================================
-- 5. Realtime Subscriptions
-- ============================================================================
-- Enable realtime for session events
ALTER PUBLICATION supabase_realtime ADD TABLE e2ee_session_events;
ALTER PUBLICATION supabase_realtime ADD TABLE e2ee_session_commands;
-- ============================================================================
-- 6. Comments for Documentation
-- ============================================================================
COMMENT ON TABLE e2ee_session_commands IS 'Commands for E2EE session management (reset, cleanup, key refresh). Processed by client and edge functions.';
COMMENT ON TABLE e2ee_session_events IS 'Realtime events for E2EE session management. Triggers client-side actions.';
COMMENT ON TABLE e2ee_decryption_failures IS 'Logs of decryption failures for debugging and automatic recovery.';
COMMENT ON COLUMN e2ee_decryption_failures.error_type IS 'Type of decryption error (e.g., "mac_failure", "session_mismatch")';
COMMENT ON COLUMN e2ee_decryption_failures.recovery_method IS 'Method used for recovery (e.g., "session_reset", "key_refresh")';
-- ============================================================================
-- 7. Helper Functions
-- ============================================================================
-- Function to log decryption failures
CREATE OR REPLACE FUNCTION log_decryption_failure(
p_message_id UUID,
p_sender_id UUID,
p_recipient_id UUID,
p_conversation_id UUID,
p_error_type TEXT,
p_error_message TEXT,
p_stack_trace TEXT,
p_ciphertext_length INTEGER,
p_message_type INTEGER,
p_session_key_exists BOOLEAN,
p_ephemeral_key_in_header BOOLEAN,
p_header_data JSONB
) RETURNS VOID AS $$
BEGIN
INSERT INTO e2ee_decryption_failures (
message_id, sender_id, recipient_id, conversation_id,
error_type, error_message, stack_trace, ciphertext_length, message_type,
session_key_exists, ephemeral_key_in_header, header_data
) VALUES (
p_message_id, p_sender_id, p_recipient_id, p_conversation_id,
p_error_type, p_error_message, p_stack_trace, p_ciphertext_length, p_message_type,
p_session_key_exists, p_ephemeral_key_in_header, p_header_data
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to mark recovery attempts
CREATE OR REPLACE FUNCTION mark_recovery_attempt(
p_failure_id UUID,
p_recovery_method TEXT,
p_success BOOLEAN
) RETURNS VOID AS $$
BEGIN
UPDATE e2ee_decryption_failures
SET
recovery_attempted = TRUE,
recovery_success = p_success,
recovery_method = p_recovery_method,
last_updated = NOW()
WHERE id = p_failure_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================
-- 8. Cleanup Function
-- ============================================================================
-- Function to cleanup old session data
CREATE OR REPLACE FUNCTION cleanup_old_e2ee_data()
RETURNS VOID AS $$
BEGIN
-- Delete old commands (older than 30 days)
DELETE FROM e2ee_session_commands
WHERE created_at < NOW() - INTERVAL '30 days';
-- Delete old events (older than 7 days)
DELETE FROM e2ee_session_events
WHERE timestamp < NOW() - INTERVAL '7 days';
-- Delete old failure logs (older than 90 days)
DELETE FROM e2ee_decryption_failures
WHERE created_at < NOW() - INTERVAL '90 days';
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

View file

@ -0,0 +1,255 @@
-- ============================================================================
-- E2EE Session State Tracking
-- ============================================================================
-- Server-side session state tracking to detect and recover from session
-- mismatches between parties. The server cannot see session keys, only
-- metadata about whether sessions exist.
-- ============================================================================
-- ============================================================================
-- 1. Session State Table
-- ============================================================================
-- Track which users have established sessions with each other
-- This allows detection of asymmetric session states (one party has session, other doesn't)
CREATE TABLE IF NOT EXISTS e2ee_session_state (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Session participants (always stored with user_id < peer_id for consistency)
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
peer_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
-- Session state flags
user_has_session BOOLEAN NOT NULL DEFAULT FALSE,
peer_has_session BOOLEAN NOT NULL DEFAULT FALSE,
-- Session metadata (no actual keys stored!)
user_session_created_at TIMESTAMPTZ,
peer_session_created_at TIMESTAMPTZ,
-- Version tracking for conflict resolution
user_session_version INTEGER NOT NULL DEFAULT 0,
peer_session_version INTEGER NOT NULL DEFAULT 0,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Ensure unique pair (user_id should always be < peer_id)
CONSTRAINT e2ee_session_state_pair_unique UNIQUE (user_id, peer_id),
CONSTRAINT e2ee_session_state_ordering CHECK (user_id < peer_id)
);
-- Indexes for efficient lookups
CREATE INDEX IF NOT EXISTS idx_session_state_user ON e2ee_session_state(user_id);
CREATE INDEX IF NOT EXISTS idx_session_state_peer ON e2ee_session_state(peer_id);
CREATE INDEX IF NOT EXISTS idx_session_state_mismatch ON e2ee_session_state(user_has_session, peer_has_session)
WHERE user_has_session != peer_has_session;
-- ============================================================================
-- 2. RLS Policies
-- ============================================================================
ALTER TABLE e2ee_session_state ENABLE ROW LEVEL SECURITY;
-- Users can see session state for their own sessions
CREATE POLICY session_state_select_own ON e2ee_session_state
FOR SELECT USING (auth.uid() = user_id OR auth.uid() = peer_id);
-- Users can only insert/update their own side of the session
-- (handled via function to enforce ordering)
-- ============================================================================
-- 3. Helper Functions
-- ============================================================================
-- Function to update session state (handles ordering automatically)
CREATE OR REPLACE FUNCTION update_e2ee_session_state(
p_user_id UUID,
p_peer_id UUID,
p_has_session BOOLEAN
) RETURNS JSONB AS $$
DECLARE
v_lower_id UUID;
v_higher_id UUID;
v_is_user_lower BOOLEAN;
v_result JSONB;
v_session_state RECORD;
BEGIN
-- Determine ordering (user_id < peer_id constraint)
IF p_user_id < p_peer_id THEN
v_lower_id := p_user_id;
v_higher_id := p_peer_id;
v_is_user_lower := TRUE;
ELSE
v_lower_id := p_peer_id;
v_higher_id := p_user_id;
v_is_user_lower := FALSE;
END IF;
-- Insert or update
INSERT INTO e2ee_session_state (user_id, peer_id, user_has_session, peer_has_session, user_session_created_at, peer_session_created_at, user_session_version, peer_session_version)
VALUES (
v_lower_id,
v_higher_id,
CASE WHEN v_is_user_lower THEN p_has_session ELSE FALSE END,
CASE WHEN NOT v_is_user_lower THEN p_has_session ELSE FALSE END,
CASE WHEN v_is_user_lower AND p_has_session THEN NOW() ELSE NULL END,
CASE WHEN NOT v_is_user_lower AND p_has_session THEN NOW() ELSE NULL END,
CASE WHEN v_is_user_lower THEN 1 ELSE 0 END,
CASE WHEN NOT v_is_user_lower THEN 1 ELSE 0 END
)
ON CONFLICT (user_id, peer_id) DO UPDATE SET
user_has_session = CASE
WHEN v_is_user_lower THEN p_has_session
ELSE e2ee_session_state.user_has_session
END,
peer_has_session = CASE
WHEN NOT v_is_user_lower THEN p_has_session
ELSE e2ee_session_state.peer_has_session
END,
user_session_created_at = CASE
WHEN v_is_user_lower AND p_has_session AND e2ee_session_state.user_session_created_at IS NULL THEN NOW()
WHEN v_is_user_lower AND NOT p_has_session THEN NULL
ELSE e2ee_session_state.user_session_created_at
END,
peer_session_created_at = CASE
WHEN NOT v_is_user_lower AND p_has_session AND e2ee_session_state.peer_session_created_at IS NULL THEN NOW()
WHEN NOT v_is_user_lower AND NOT p_has_session THEN NULL
ELSE e2ee_session_state.peer_session_created_at
END,
user_session_version = CASE
WHEN v_is_user_lower THEN e2ee_session_state.user_session_version + 1
ELSE e2ee_session_state.user_session_version
END,
peer_session_version = CASE
WHEN NOT v_is_user_lower THEN e2ee_session_state.peer_session_version + 1
ELSE e2ee_session_state.peer_session_version
END,
updated_at = NOW()
RETURNING * INTO v_session_state;
-- Build result with mismatch detection
v_result := jsonb_build_object(
'success', TRUE,
'user_has_session', CASE WHEN v_is_user_lower THEN v_session_state.user_has_session ELSE v_session_state.peer_has_session END,
'peer_has_session', CASE WHEN v_is_user_lower THEN v_session_state.peer_has_session ELSE v_session_state.user_has_session END,
'session_mismatch', v_session_state.user_has_session != v_session_state.peer_has_session,
'peer_session_version', CASE WHEN v_is_user_lower THEN v_session_state.peer_session_version ELSE v_session_state.user_session_version END
);
RETURN v_result;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to get session state between two users
CREATE OR REPLACE FUNCTION get_e2ee_session_state(
p_user_id UUID,
p_peer_id UUID
) RETURNS JSONB AS $$
DECLARE
v_lower_id UUID;
v_higher_id UUID;
v_is_user_lower BOOLEAN;
v_session_state RECORD;
BEGIN
-- Determine ordering
IF p_user_id < p_peer_id THEN
v_lower_id := p_user_id;
v_higher_id := p_peer_id;
v_is_user_lower := TRUE;
ELSE
v_lower_id := p_peer_id;
v_higher_id := p_user_id;
v_is_user_lower := FALSE;
END IF;
SELECT * INTO v_session_state
FROM e2ee_session_state
WHERE user_id = v_lower_id AND peer_id = v_higher_id;
IF NOT FOUND THEN
RETURN jsonb_build_object(
'exists', FALSE,
'user_has_session', FALSE,
'peer_has_session', FALSE,
'session_mismatch', FALSE
);
END IF;
RETURN jsonb_build_object(
'exists', TRUE,
'user_has_session', CASE WHEN v_is_user_lower THEN v_session_state.user_has_session ELSE v_session_state.peer_has_session END,
'peer_has_session', CASE WHEN v_is_user_lower THEN v_session_state.peer_has_session ELSE v_session_state.user_has_session END,
'session_mismatch', v_session_state.user_has_session != v_session_state.peer_has_session,
'user_session_version', CASE WHEN v_is_user_lower THEN v_session_state.user_session_version ELSE v_session_state.peer_session_version END,
'peer_session_version', CASE WHEN v_is_user_lower THEN v_session_state.peer_session_version ELSE v_session_state.user_session_version END
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to clear session state (when resetting)
CREATE OR REPLACE FUNCTION clear_e2ee_session_state(
p_user_id UUID,
p_peer_id UUID
) RETURNS JSONB AS $$
DECLARE
v_lower_id UUID;
v_higher_id UUID;
v_is_user_lower BOOLEAN;
BEGIN
-- Determine ordering
IF p_user_id < p_peer_id THEN
v_lower_id := p_user_id;
v_higher_id := p_peer_id;
v_is_user_lower := TRUE;
ELSE
v_lower_id := p_peer_id;
v_higher_id := p_user_id;
v_is_user_lower := FALSE;
END IF;
-- Update only the caller's side of the session
UPDATE e2ee_session_state SET
user_has_session = CASE WHEN v_is_user_lower THEN FALSE ELSE user_has_session END,
peer_has_session = CASE WHEN NOT v_is_user_lower THEN FALSE ELSE peer_has_session END,
user_session_created_at = CASE WHEN v_is_user_lower THEN NULL ELSE user_session_created_at END,
peer_session_created_at = CASE WHEN NOT v_is_user_lower THEN NULL ELSE peer_session_created_at END,
user_session_version = CASE WHEN v_is_user_lower THEN user_session_version + 1 ELSE user_session_version END,
peer_session_version = CASE WHEN NOT v_is_user_lower THEN peer_session_version + 1 ELSE peer_session_version END,
updated_at = NOW()
WHERE user_id = v_lower_id AND peer_id = v_higher_id;
RETURN jsonb_build_object('success', TRUE);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================
-- 4. Add decryption_failure event type if not exists
-- ============================================================================
-- Update the check constraint to include session_mismatch event type
ALTER TABLE e2ee_session_events
DROP CONSTRAINT IF EXISTS e2ee_session_events_event_type_check;
ALTER TABLE e2ee_session_events
ADD CONSTRAINT e2ee_session_events_event_type_check
CHECK (event_type IN ('session_reset', 'conversation_cleanup', 'key_refresh', 'decryption_failure', 'session_mismatch', 'session_established'));
-- ============================================================================
-- 5. Realtime for session state changes
-- ============================================================================
ALTER PUBLICATION supabase_realtime ADD TABLE e2ee_session_state;
-- ============================================================================
-- 6. Comments
-- ============================================================================
COMMENT ON TABLE e2ee_session_state IS 'Server-side tracking of E2EE session existence between user pairs. Does NOT store actual keys.';
COMMENT ON COLUMN e2ee_session_state.user_has_session IS 'Whether the user with smaller UUID has an active session';
COMMENT ON COLUMN e2ee_session_state.peer_has_session IS 'Whether the user with larger UUID has an active session';
COMMENT ON COLUMN e2ee_session_state.user_session_version IS 'Incremented each time user updates their session state';
COMMENT ON FUNCTION update_e2ee_session_state IS 'Update session state for a user-peer pair. Handles UUID ordering automatically.';
COMMENT ON FUNCTION get_e2ee_session_state IS 'Get session state between two users. Returns mismatch detection.';
COMMENT ON FUNCTION clear_e2ee_session_state IS 'Clear session state for a user (used during session reset).';

View file

@ -0,0 +1,135 @@
-- Fix for recurring post/appreciate access issues for specific users
-- This migration ensures:
-- 1. All posts have valid visibility values
-- 2. Profile privacy settings are consistent
-- 3. RLS policies don't conflict
-- 4. Users can always see public posts regardless of their own privacy settings
-- Step 1: Ensure all posts have visibility set (fix any nulls)
UPDATE posts
SET visibility = 'public'
WHERE visibility IS NULL;
-- Step 2: Ensure profiles have consistent privacy settings
UPDATE profiles
SET is_private = false
WHERE is_private IS NULL;
-- Step 2b: Official accounts should always be public (is_private = false)
-- This is because official/verified accounts are meant to be publicly accessible
UPDATE profiles
SET is_private = false
WHERE is_official = true AND is_private = true;
-- Step 3: Drop ALL conflicting policies and create a unified one
DROP POLICY IF EXISTS posts_select_private_model ON posts;
DROP POLICY IF EXISTS posts_select_policy ON posts;
DROP POLICY IF EXISTS posts_visibility_policy ON posts;
DROP POLICY IF EXISTS posts_select_unified ON posts;
-- Unified SELECT policy that combines both models:
-- ANY authenticated user can see post if:
-- a) They are the author (always see own posts)
-- b) Post visibility is 'public' (ANYONE can see public posts)
-- c) Post visibility is 'followers' AND user has accepted follow to author
-- d) Author's profile is NOT private (legacy backward compat - treat as public)
--
-- IMPORTANT: The viewer's own is_private setting does NOT affect what they can see.
-- is_private only affects whether OTHERS can see YOUR posts without following you.
CREATE POLICY posts_select_unified ON posts
FOR SELECT
USING (
-- Author can always see their own posts
auth.uid() = author_id
-- Public posts visible to ALL authenticated users
OR visibility = 'public'
-- Followers-only posts visible to accepted followers
OR (
visibility = 'followers'
AND EXISTS (
SELECT 1
FROM follows f
WHERE f.follower_id = auth.uid()
AND f.following_id = posts.author_id
AND f.status = 'accepted'
)
)
-- Legacy: If author's profile is NOT private, treat their non-private posts as visible
-- This handles posts created before visibility column existed
OR (
visibility IS DISTINCT FROM 'private'
AND EXISTS (
SELECT 1
FROM profiles p
WHERE p.id = posts.author_id
AND p.is_private = false
)
)
);
-- Step 4: Ensure INSERT policy exists for posts
DROP POLICY IF EXISTS posts_insert_policy ON posts;
CREATE POLICY posts_insert_policy ON posts
FOR INSERT
WITH CHECK (auth.uid() = author_id);
-- Step 5: Ensure UPDATE policy exists for posts
DROP POLICY IF EXISTS posts_update_policy ON posts;
CREATE POLICY posts_update_policy ON posts
FOR UPDATE
USING (auth.uid() = author_id)
WITH CHECK (auth.uid() = author_id);
-- Step 6: Ensure DELETE policy exists for posts
DROP POLICY IF EXISTS posts_delete_policy ON posts;
CREATE POLICY posts_delete_policy ON posts
FOR DELETE
USING (auth.uid() = author_id);
-- Step 7: Fix post_likes table RLS
ALTER TABLE IF EXISTS post_likes ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS post_likes_select_policy ON post_likes;
CREATE POLICY post_likes_select_policy ON post_likes
FOR SELECT
USING (true); -- Anyone can see likes (needed for like counts)
DROP POLICY IF EXISTS post_likes_insert_policy ON post_likes;
CREATE POLICY post_likes_insert_policy ON post_likes
FOR INSERT
WITH CHECK (auth.uid() = user_id);
DROP POLICY IF EXISTS post_likes_delete_policy ON post_likes;
CREATE POLICY post_likes_delete_policy ON post_likes
FOR DELETE
USING (auth.uid() = user_id);
-- Step 8: Create index for faster RLS checks
CREATE INDEX IF NOT EXISTS idx_follows_follower_following_status
ON follows(follower_id, following_id, status);
CREATE INDEX IF NOT EXISTS idx_posts_author_visibility
ON posts(author_id, visibility);
-- Step 7b: Fix post_saves table RLS (same pattern as post_likes)
ALTER TABLE IF EXISTS post_saves ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS post_saves_select_policy ON post_saves;
CREATE POLICY post_saves_select_policy ON post_saves
FOR SELECT
USING (auth.uid() = user_id); -- Users can only see their own saves
DROP POLICY IF EXISTS post_saves_insert_policy ON post_saves;
CREATE POLICY post_saves_insert_policy ON post_saves
FOR INSERT
WITH CHECK (auth.uid() = user_id);
DROP POLICY IF EXISTS post_saves_delete_policy ON post_saves;
CREATE POLICY post_saves_delete_policy ON post_saves
FOR DELETE
USING (auth.uid() = user_id);
-- Step 9: Grant necessary permissions
GRANT SELECT, INSERT, UPDATE, DELETE ON posts TO authenticated;
GRANT SELECT, INSERT, DELETE ON post_likes TO authenticated;
GRANT SELECT, INSERT, DELETE ON post_saves TO authenticated;

View file

@ -0,0 +1,24 @@
-- Add archiving for notifications
alter table notifications
add column if not exists archived_at timestamptz;
create index if not exists idx_notifications_user_archived
on notifications (user_id, archived_at, created_at desc);
-- Update unread count to ignore archived notifications
create or replace function get_unread_notification_count(p_user_id uuid)
returns integer
language plpgsql
stable
security definer
as $$
begin
return (
select count(*)::integer
from notifications
where user_id = p_user_id
and is_read = false
and archived_at is null
);
end;
$$;

View file

@ -0,0 +1,24 @@
-- ============================================================================
-- NOTIFICATION RPC FUNCTIONS
-- Helper functions for notification queries
-- ============================================================================
-- Get unread notification count for a user
create or replace function get_unread_notification_count(p_user_id uuid)
returns integer
language plpgsql
stable
security definer
as $$
begin
return (
select count(*)::integer
from notifications
where user_id = p_user_id
and is_read = false
);
end;
$$;
-- Grant execute permission to authenticated users
grant execute on function get_unread_notification_count(uuid) to authenticated;

View file

@ -0,0 +1,294 @@
-- ============================================================================
-- NOTIFICATION TRIGGERS
-- Automatically create notifications for appreciates, comments, mentions, chains
-- ============================================================================
-- ===========================================
-- 1. APPRECIATE (LIKE) NOTIFICATIONS
-- ===========================================
create or replace function handle_appreciate_notification()
returns trigger
language plpgsql
security definer
as $$
declare
post_author_id uuid;
begin
-- Get the post author
select author_id into post_author_id
from posts
where id = new.post_id;
-- Don't notify if user likes their own post
if post_author_id is null or post_author_id = new.user_id then
return new;
end if;
-- Insert notification
insert into notifications (user_id, type, actor_id, post_id, metadata)
values (
post_author_id,
'appreciate',
new.user_id,
new.post_id,
jsonb_build_object(
'post_id', new.post_id,
'liker_id', new.user_id
)
);
return new;
end;
$$;
drop trigger if exists appreciate_notification_trigger on post_likes;
create trigger appreciate_notification_trigger
after insert on post_likes
for each row execute function handle_appreciate_notification();
-- ===========================================
-- 2. COMMENT NOTIFICATIONS
-- ===========================================
create or replace function handle_comment_notification()
returns trigger
language plpgsql
security definer
as $$
declare
post_author_id uuid;
begin
-- Get the post author
select author_id into post_author_id
from posts
where id = new.post_id;
-- Don't notify if user comments on their own post
if post_author_id is null or post_author_id = new.author_id then
return new;
end if;
-- Insert notification
insert into notifications (user_id, type, actor_id, post_id, comment_id, metadata)
values (
post_author_id,
'comment',
new.author_id,
new.post_id,
new.id,
jsonb_build_object(
'post_id', new.post_id,
'comment_id', new.id,
'comment_preview', left(new.body, 100)
)
);
return new;
end;
$$;
drop trigger if exists comment_notification_trigger on comments;
create trigger comment_notification_trigger
after insert on comments
for each row execute function handle_comment_notification();
-- ===========================================
-- 3. MENTION NOTIFICATIONS
-- ===========================================
create or replace function handle_mention_notification()
returns trigger
language plpgsql
security definer
as $$
declare
mentioned_handle text;
mentioned_user_id uuid;
mention_matches text[];
begin
-- Extract @mentions from the body using regex
-- Matches @handle patterns (lowercase letters, numbers, underscores, 3-20 chars)
for mentioned_handle in
select (regexp_matches(new.body, '@([a-z0-9_]{3,20})', 'gi'))[1]
loop
-- Look up the mentioned user
select id into mentioned_user_id
from profiles
where lower(handle) = lower(mentioned_handle);
-- Skip if user not found or mentioning self
if mentioned_user_id is null or mentioned_user_id = new.author_id then
continue;
end if;
-- Insert mention notification (for comments)
insert into notifications (user_id, type, actor_id, post_id, comment_id, metadata)
values (
mentioned_user_id,
'mention',
new.author_id,
new.post_id,
new.id,
jsonb_build_object(
'post_id', new.post_id,
'comment_id', new.id,
'mentioned_in', 'comment',
'preview', left(new.body, 100)
)
)
on conflict do nothing; -- Avoid duplicate notifications
end loop;
return new;
end;
$$;
drop trigger if exists mention_notification_trigger on comments;
create trigger mention_notification_trigger
after insert on comments
for each row execute function handle_mention_notification();
-- Also handle mentions in posts
create or replace function handle_post_mention_notification()
returns trigger
language plpgsql
security definer
as $$
declare
mentioned_handle text;
mentioned_user_id uuid;
begin
-- Extract @mentions from the post body
for mentioned_handle in
select (regexp_matches(new.body, '@([a-z0-9_]{3,20})', 'gi'))[1]
loop
-- Look up the mentioned user
select id into mentioned_user_id
from profiles
where lower(handle) = lower(mentioned_handle);
-- Skip if user not found or mentioning self
if mentioned_user_id is null or mentioned_user_id = new.author_id then
continue;
end if;
-- Insert mention notification (for posts)
insert into notifications (user_id, type, actor_id, post_id, metadata)
values (
mentioned_user_id,
'mention',
new.author_id,
new.id,
jsonb_build_object(
'post_id', new.id,
'mentioned_in', 'post',
'preview', left(new.body, 100)
)
)
on conflict do nothing;
end loop;
return new;
end;
$$;
drop trigger if exists post_mention_notification_trigger on posts;
create trigger post_mention_notification_trigger
after insert on posts
for each row execute function handle_post_mention_notification();
-- ===========================================
-- 4. CHAIN (REPOST) NOTIFICATIONS
-- ===========================================
create or replace function handle_chain_notification()
returns trigger
language plpgsql
security definer
as $$
declare
parent_author_id uuid;
begin
-- Only trigger if this is a chain (has parent)
if new.chain_parent_id is null then
return new;
end if;
-- Get the parent post author
select author_id into parent_author_id
from posts
where id = new.chain_parent_id;
-- Don't notify if user chains their own post
if parent_author_id is null or parent_author_id = new.author_id then
return new;
end if;
-- Insert notification
insert into notifications (user_id, type, actor_id, post_id, metadata)
values (
parent_author_id,
'chain',
new.author_id,
new.chain_parent_id, -- Reference the original post
jsonb_build_object(
'original_post_id', new.chain_parent_id,
'chain_post_id', new.id,
'chain_preview', left(new.body, 100)
)
);
return new;
end;
$$;
drop trigger if exists chain_notification_trigger on posts;
create trigger chain_notification_trigger
after insert on posts
for each row execute function handle_chain_notification();
-- ===========================================
-- 5. ADD UNIQUE CONSTRAINT TO PREVENT DUPLICATES
-- ===========================================
-- Prevent duplicate notifications for the same action
-- (e.g., user can't appreciate same post twice anyway, but this adds safety)
create unique index if not exists idx_notifications_unique_appreciate
on notifications (user_id, type, actor_id, post_id)
where type = 'appreciate' and comment_id is null;
create unique index if not exists idx_notifications_unique_comment
on notifications (user_id, type, actor_id, comment_id)
where type = 'comment' and comment_id is not null;
create unique index if not exists idx_notifications_unique_chain
on notifications (user_id, type, actor_id, (metadata->>'chain_post_id'))
where type = 'chain';
-- ===========================================
-- 6. ADD INDEX FOR FASTER NOTIFICATION QUERIES
-- ===========================================
create index if not exists idx_notifications_user_unread
on notifications (user_id, is_read, created_at desc)
where is_read = false;
create index if not exists idx_notifications_user_created
on notifications (user_id, created_at desc);
-- ===========================================
-- 7. ENABLE REALTIME FOR NOTIFICATIONS
-- ===========================================
-- This allows clients to subscribe to notification changes
do $$
begin
if not exists (
select 1 from pg_publication_tables
where pubname = 'supabase_realtime'
and tablename = 'notifications'
) then
alter publication supabase_realtime add table notifications;
end if;
end $$;

Some files were not shown because too many files have changed in this diff Show more