Compare commits
No commits in common. "629db7276438a8d34a04390126e4bad4a5d5b00d" and "35aca309c980d3e979e6be80d73be941d10b7100" have entirely different histories.
629db72764
...
35aca309c9
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,6 +2,3 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|||||||
@ -14,11 +14,7 @@
|
|||||||
# DATABASE_URL="postgres://anydrop:${POSTGRES_PASSWORD}@postgres.anydrop.svc.cluster.local:5432/anydrop"
|
# DATABASE_URL="postgres://anydrop:${POSTGRES_PASSWORD}@postgres.anydrop.svc.cluster.local:5432/anydrop"
|
||||||
# kubectl -n anydrop create secret generic anydrop-app-secrets \
|
# kubectl -n anydrop create secret generic anydrop-app-secrets \
|
||||||
# --from-literal=SESSION_SECRET="$SESSION_SECRET" \
|
# --from-literal=SESSION_SECRET="$SESSION_SECRET" \
|
||||||
# --from-literal=DATABASE_URL="$DATABASE_URL" \
|
# --from-literal=DATABASE_URL="$DATABASE_URL"
|
||||||
# --from-literal=STRIPE_SECRET_KEY="sk_live_…" \
|
|
||||||
# --from-literal=STRIPE_WEBHOOK_SECRET="whsec_…" \
|
|
||||||
# --from-literal=STRIPE_PRICE_MONTHLY="price_…" \
|
|
||||||
# --from-literal=STRIPE_PRICE_YEARLY="price_…"
|
|
||||||
#
|
#
|
||||||
# # MinIO — reuses the shared cluster MinIO in the `minio` namespace.
|
# # MinIO — reuses the shared cluster MinIO in the `minio` namespace.
|
||||||
# # Create a scoped user + policy on MinIO (one-shot), then store its
|
# # Create a scoped user + policy on MinIO (one-shot), then store its
|
||||||
@ -57,13 +53,6 @@ type: Opaque
|
|||||||
stringData:
|
stringData:
|
||||||
SESSION_SECRET: CHANGE_ME_64_BYTE_RANDOM_STRING
|
SESSION_SECRET: CHANGE_ME_64_BYTE_RANDOM_STRING
|
||||||
DATABASE_URL: postgres://anydrop:CHANGE_ME@postgres.anydrop.svc.cluster.local:5432/anydrop
|
DATABASE_URL: postgres://anydrop:CHANGE_ME@postgres.anydrop.svc.cluster.local:5432/anydrop
|
||||||
# Phase 3 — Stripe billing. Create the product + recurring prices in the
|
|
||||||
# Stripe dashboard, then fill these in. Leaving them unset disables the
|
|
||||||
# /api/billing/* and webhook routes gracefully (503).
|
|
||||||
STRIPE_SECRET_KEY: CHANGE_ME_sk_live_xxx
|
|
||||||
STRIPE_WEBHOOK_SECRET: CHANGE_ME_whsec_xxx
|
|
||||||
STRIPE_PRICE_MONTHLY: CHANGE_ME_price_xxx
|
|
||||||
STRIPE_PRICE_YEARLY: CHANGE_ME_price_xxx
|
|
||||||
|
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@ -44,9 +44,6 @@ importers:
|
|||||||
postgres:
|
postgres:
|
||||||
specifier: ^3.4.5
|
specifier: ^3.4.5
|
||||||
version: 3.4.9
|
version: 3.4.9
|
||||||
stripe:
|
|
||||||
specifier: ^22.0.2
|
|
||||||
version: 22.0.2(@types/node@22.19.17)
|
|
||||||
web-push:
|
web-push:
|
||||||
specifier: ^3.6.7
|
specifier: ^3.6.7
|
||||||
version: 3.6.7
|
version: 3.6.7
|
||||||
@ -3293,15 +3290,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==}
|
resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
stripe@22.0.2:
|
|
||||||
resolution: {integrity: sha512-2/BLrQ3oB1zlNfeL/LfHFjTGx6EQn0j+ztrrTJHuDjV5VVIpk92oSDaxyKLUr3pG3dnee2LZqhFUv2Bf0G1/3g==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
peerDependencies:
|
|
||||||
'@types/node': '>=18'
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@types/node':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
strnum@2.2.3:
|
strnum@2.2.3:
|
||||||
resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==}
|
resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==}
|
||||||
|
|
||||||
@ -7288,10 +7276,6 @@ snapshots:
|
|||||||
|
|
||||||
strip-comments@2.0.1: {}
|
strip-comments@2.0.1: {}
|
||||||
|
|
||||||
stripe@22.0.2(@types/node@22.19.17):
|
|
||||||
optionalDependencies:
|
|
||||||
'@types/node': 22.19.17
|
|
||||||
|
|
||||||
strnum@2.2.3: {}
|
strnum@2.2.3: {}
|
||||||
|
|
||||||
sucrase@3.35.1:
|
sucrase@3.35.1:
|
||||||
|
|||||||
@ -23,7 +23,6 @@
|
|||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"stripe": "^22.0.2",
|
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"ws": "^8.18.1"
|
"ws": "^8.18.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
ALTER TABLE "users" ADD COLUMN "stripe_subscription_id" text;--> statement-breakpoint
|
|
||||||
ALTER TABLE "users" ADD COLUMN "plan_status" text;--> statement-breakpoint
|
|
||||||
ALTER TABLE "users" ADD COLUMN "plan_expires_at" timestamp with time zone;
|
|
||||||
@ -1,577 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "715a57a4-85a8-4321-82bb-45b7dadd863e",
|
|
||||||
"prevId": "006000af-0a3b-4b14-8391-1adbd1aba3c4",
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"tables": {
|
|
||||||
"public.magic_links": {
|
|
||||||
"name": "magic_links",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "gen_random_uuid()"
|
|
||||||
},
|
|
||||||
"email": {
|
|
||||||
"name": "email",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"token_hash": {
|
|
||||||
"name": "token_hash",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
},
|
|
||||||
"expires_at": {
|
|
||||||
"name": "expires_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"used_at": {
|
|
||||||
"name": "used_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {
|
|
||||||
"magic_links_token_hash_unique": {
|
|
||||||
"name": "magic_links_token_hash_unique",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "token_hash",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": true,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
},
|
|
||||||
"magic_links_email_idx": {
|
|
||||||
"name": "magic_links_email_idx",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "email",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
},
|
|
||||||
"public.sessions": {
|
|
||||||
"name": "sessions",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "gen_random_uuid()"
|
|
||||||
},
|
|
||||||
"user_id": {
|
|
||||||
"name": "user_id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"token_hash": {
|
|
||||||
"name": "token_hash",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"user_agent": {
|
|
||||||
"name": "user_agent",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"ip_hash": {
|
|
||||||
"name": "ip_hash",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
},
|
|
||||||
"last_used_at": {
|
|
||||||
"name": "last_used_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
},
|
|
||||||
"expires_at": {
|
|
||||||
"name": "expires_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {
|
|
||||||
"sessions_token_hash_unique": {
|
|
||||||
"name": "sessions_token_hash_unique",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "token_hash",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": true,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
},
|
|
||||||
"sessions_user_idx": {
|
|
||||||
"name": "sessions_user_idx",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "user_id",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreignKeys": {
|
|
||||||
"sessions_user_id_users_id_fk": {
|
|
||||||
"name": "sessions_user_id_users_id_fk",
|
|
||||||
"tableFrom": "sessions",
|
|
||||||
"tableTo": "users",
|
|
||||||
"columnsFrom": [
|
|
||||||
"user_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
},
|
|
||||||
"public.transfers": {
|
|
||||||
"name": "transfers",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "gen_random_uuid()"
|
|
||||||
},
|
|
||||||
"storage_key": {
|
|
||||||
"name": "storage_key",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"sender_user_id": {
|
|
||||||
"name": "sender_user_id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"sender_device_id": {
|
|
||||||
"name": "sender_device_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"recipient_user_id": {
|
|
||||||
"name": "recipient_user_id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"recipient_email_hash": {
|
|
||||||
"name": "recipient_email_hash",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"encrypted_metadata": {
|
|
||||||
"name": "encrypted_metadata",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"size_bytes": {
|
|
||||||
"name": "size_bytes",
|
|
||||||
"type": "bigint",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"max_downloads": {
|
|
||||||
"name": "max_downloads",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": 1
|
|
||||||
},
|
|
||||||
"download_count": {
|
|
||||||
"name": "download_count",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": 0
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
},
|
|
||||||
"expires_at": {
|
|
||||||
"name": "expires_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"first_download_at": {
|
|
||||||
"name": "first_download_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"deleted_at": {
|
|
||||||
"name": "deleted_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"password_hash": {
|
|
||||||
"name": "password_hash",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {
|
|
||||||
"transfers_sender_idx": {
|
|
||||||
"name": "transfers_sender_idx",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "sender_user_id",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
},
|
|
||||||
"transfers_recipient_idx": {
|
|
||||||
"name": "transfers_recipient_idx",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "recipient_user_id",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
},
|
|
||||||
"transfers_expires_idx": {
|
|
||||||
"name": "transfers_expires_idx",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "expires_at",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreignKeys": {
|
|
||||||
"transfers_sender_user_id_users_id_fk": {
|
|
||||||
"name": "transfers_sender_user_id_users_id_fk",
|
|
||||||
"tableFrom": "transfers",
|
|
||||||
"tableTo": "users",
|
|
||||||
"columnsFrom": [
|
|
||||||
"sender_user_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "set null",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
},
|
|
||||||
"transfers_recipient_user_id_users_id_fk": {
|
|
||||||
"name": "transfers_recipient_user_id_users_id_fk",
|
|
||||||
"tableFrom": "transfers",
|
|
||||||
"tableTo": "users",
|
|
||||||
"columnsFrom": [
|
|
||||||
"recipient_user_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "set null",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
},
|
|
||||||
"public.user_devices": {
|
|
||||||
"name": "user_devices",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "gen_random_uuid()"
|
|
||||||
},
|
|
||||||
"user_id": {
|
|
||||||
"name": "user_id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"device_id": {
|
|
||||||
"name": "device_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"name": "name",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"type": {
|
|
||||||
"name": "type",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"avatar": {
|
|
||||||
"name": "avatar",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"linked_at": {
|
|
||||||
"name": "linked_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
},
|
|
||||||
"last_seen_at": {
|
|
||||||
"name": "last_seen_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {
|
|
||||||
"user_devices_user_device_unique": {
|
|
||||||
"name": "user_devices_user_device_unique",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "user_id",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"expression": "device_id",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": true,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreignKeys": {
|
|
||||||
"user_devices_user_id_users_id_fk": {
|
|
||||||
"name": "user_devices_user_id_users_id_fk",
|
|
||||||
"tableFrom": "user_devices",
|
|
||||||
"tableTo": "users",
|
|
||||||
"columnsFrom": [
|
|
||||||
"user_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
},
|
|
||||||
"public.users": {
|
|
||||||
"name": "users",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "gen_random_uuid()"
|
|
||||||
},
|
|
||||||
"email": {
|
|
||||||
"name": "email",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"plan": {
|
|
||||||
"name": "plan",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "'free'"
|
|
||||||
},
|
|
||||||
"stripe_customer_id": {
|
|
||||||
"name": "stripe_customer_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"stripe_subscription_id": {
|
|
||||||
"name": "stripe_subscription_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"plan_status": {
|
|
||||||
"name": "plan_status",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"plan_expires_at": {
|
|
||||||
"name": "plan_expires_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
},
|
|
||||||
"updated_at": {
|
|
||||||
"name": "updated_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {
|
|
||||||
"users_email_unique": {
|
|
||||||
"name": "users_email_unique",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "email",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": true,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"enums": {},
|
|
||||||
"schemas": {},
|
|
||||||
"sequences": {},
|
|
||||||
"roles": {},
|
|
||||||
"policies": {},
|
|
||||||
"views": {},
|
|
||||||
"_meta": {
|
|
||||||
"columns": {},
|
|
||||||
"schemas": {},
|
|
||||||
"tables": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -22,13 +22,6 @@
|
|||||||
"when": 1776677642835,
|
"when": 1776677642835,
|
||||||
"tag": "0002_typical_northstar",
|
"tag": "0002_typical_northstar",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 3,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1776684213404,
|
|
||||||
"tag": "0003_stripe_subscription",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -7,9 +7,6 @@ export const users = pgTable(
|
|||||||
email: text("email").notNull(),
|
email: text("email").notNull(),
|
||||||
plan: text("plan").notNull().default("free"),
|
plan: text("plan").notNull().default("free"),
|
||||||
stripeCustomerId: text("stripe_customer_id"),
|
stripeCustomerId: text("stripe_customer_id"),
|
||||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
|
||||||
planStatus: text("plan_status"),
|
|
||||||
planExpiresAt: timestamp("plan_expires_at", { withTimezone: true }),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3,17 +3,10 @@ import { cors } from "hono/cors";
|
|||||||
import { authRoutes } from "./auth.js";
|
import { authRoutes } from "./auth.js";
|
||||||
import { meRoutes } from "./me.js";
|
import { meRoutes } from "./me.js";
|
||||||
import { transferRoutes } from "./transfers.js";
|
import { transferRoutes } from "./transfers.js";
|
||||||
import { billingRoutes } from "./billing.js";
|
|
||||||
import { webhookRoutes } from "./webhook.js";
|
|
||||||
|
|
||||||
export function buildApp() {
|
export function buildApp() {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
// Stripe posts with its own signing — bypass CORS, which only applies to
|
|
||||||
// browser-origin requests anyway. Mount the webhook first so the broader
|
|
||||||
// /api/* CORS middleware below never sees it.
|
|
||||||
app.route("/api", webhookRoutes);
|
|
||||||
|
|
||||||
const corsOrigin = process.env.APP_URL ?? "http://localhost:5173";
|
const corsOrigin = process.env.APP_URL ?? "http://localhost:5173";
|
||||||
app.use(
|
app.use(
|
||||||
"/api/*",
|
"/api/*",
|
||||||
@ -30,7 +23,6 @@ export function buildApp() {
|
|||||||
app.route("/api/auth", authRoutes);
|
app.route("/api/auth", authRoutes);
|
||||||
app.route("/api", meRoutes);
|
app.route("/api", meRoutes);
|
||||||
app.route("/api", transferRoutes);
|
app.route("/api", transferRoutes);
|
||||||
app.route("/api", billingRoutes);
|
|
||||||
|
|
||||||
app.notFound((c) => c.json({ error: "not_found" }, 404));
|
app.notFound((c) => c.json({ error: "not_found" }, 404));
|
||||||
app.onError((err, c) => {
|
app.onError((err, c) => {
|
||||||
|
|||||||
@ -1,89 +0,0 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { db } from "../db/client.js";
|
|
||||||
import { users } from "../db/schema.js";
|
|
||||||
import { resolveSession } from "./session.js";
|
|
||||||
import { rateLimit } from "./middleware.js";
|
|
||||||
import { getStripe, isStripeConfigured, priceIdFor } from "../lib/stripe.js";
|
|
||||||
|
|
||||||
export const billingRoutes = new Hono();
|
|
||||||
|
|
||||||
billingRoutes.use("/billing/*", rateLimit(10));
|
|
||||||
|
|
||||||
function appUrl(): string {
|
|
||||||
return process.env.APP_URL ?? "http://localhost:5173";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the Stripe customer ID for the user, creating one on the fly if
|
|
||||||
* missing. Keeps the DB in sync with the Stripe side.
|
|
||||||
*/
|
|
||||||
async function ensureCustomerId(userId: string, email: string, existing: string | null): Promise<string> {
|
|
||||||
if (existing) return existing;
|
|
||||||
const stripe = getStripe();
|
|
||||||
const customer = await stripe.customers.create({
|
|
||||||
email,
|
|
||||||
metadata: { user_id: userId },
|
|
||||||
});
|
|
||||||
await db.update(users).set({ stripeCustomerId: customer.id }).where(eq(users.id, userId));
|
|
||||||
return customer.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/billing/checkout
|
|
||||||
* Body: { interval: "monthly" | "yearly" }
|
|
||||||
* Returns: { url } — the hosted Stripe Checkout URL the client should open.
|
|
||||||
*/
|
|
||||||
billingRoutes.post("/billing/checkout", async (c) => {
|
|
||||||
if (!isStripeConfigured()) return c.json({ error: "billing_not_configured" }, 503);
|
|
||||||
|
|
||||||
const user = await resolveSession(c);
|
|
||||||
if (!user) return c.json({ error: "unauthenticated" }, 401);
|
|
||||||
|
|
||||||
let body: { interval?: string };
|
|
||||||
try {
|
|
||||||
body = await c.req.json();
|
|
||||||
} catch {
|
|
||||||
return c.json({ error: "invalid_body" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const interval = body.interval === "yearly" ? "yearly" : "monthly";
|
|
||||||
const price = priceIdFor(interval);
|
|
||||||
|
|
||||||
const customerId = await ensureCustomerId(user.id, user.email, user.stripeCustomerId);
|
|
||||||
|
|
||||||
const stripe = getStripe();
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
|
||||||
mode: "subscription",
|
|
||||||
customer: customerId,
|
|
||||||
client_reference_id: user.id,
|
|
||||||
line_items: [{ price, quantity: 1 }],
|
|
||||||
success_url: `${appUrl()}/settings?checkout=success`,
|
|
||||||
cancel_url: `${appUrl()}/pricing?checkout=canceled`,
|
|
||||||
allow_promotion_codes: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session.url) return c.json({ error: "session_failed" }, 500);
|
|
||||||
return c.json({ url: session.url });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/billing/portal
|
|
||||||
* Opens the Stripe Customer Portal so the user can manage payment method,
|
|
||||||
* cancel, or view invoices.
|
|
||||||
*/
|
|
||||||
billingRoutes.post("/billing/portal", async (c) => {
|
|
||||||
if (!isStripeConfigured()) return c.json({ error: "billing_not_configured" }, 503);
|
|
||||||
|
|
||||||
const user = await resolveSession(c);
|
|
||||||
if (!user) return c.json({ error: "unauthenticated" }, 401);
|
|
||||||
if (!user.stripeCustomerId) return c.json({ error: "no_customer" }, 400);
|
|
||||||
|
|
||||||
const stripe = getStripe();
|
|
||||||
const session = await stripe.billingPortal.sessions.create({
|
|
||||||
customer: user.stripeCustomerId,
|
|
||||||
return_url: `${appUrl()}/settings`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json({ url: session.url });
|
|
||||||
});
|
|
||||||
@ -22,15 +22,7 @@ meRoutes.get("/me", async (c) => {
|
|||||||
.orderBy(userDevices.lastSeenAt);
|
.orderBy(userDevices.lastSeenAt);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
user: {
|
user: { id: user.id, email: user.email, plan: user.plan, createdAt: user.createdAt },
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
plan: user.plan,
|
|
||||||
planStatus: user.planStatus,
|
|
||||||
planExpiresAt: user.planExpiresAt,
|
|
||||||
hasStripeCustomer: !!user.stripeCustomerId,
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
},
|
|
||||||
devices: devices.map((d) => ({
|
devices: devices.map((d) => ({
|
||||||
id: d.id,
|
id: d.id,
|
||||||
deviceId: d.deviceId,
|
deviceId: d.deviceId,
|
||||||
|
|||||||
@ -6,14 +6,16 @@ import { transfers, users } from "../db/schema.js";
|
|||||||
import { resolveSession } from "./session.js";
|
import { resolveSession } from "./session.js";
|
||||||
import { rateLimit } from "./middleware.js";
|
import { rateLimit } from "./middleware.js";
|
||||||
import { deleteObject, presignDownload, presignUpload } from "../storage/s3.js";
|
import { deleteObject, presignDownload, presignUpload } from "../storage/s3.js";
|
||||||
import { limitsFor } from "../lib/plans.js";
|
|
||||||
|
|
||||||
export const transferRoutes = new Hono();
|
export const transferRoutes = new Hono();
|
||||||
|
|
||||||
const UPLOAD_TTL_SECONDS = 15 * 60;
|
const UPLOAD_TTL_SECONDS = 15 * 60;
|
||||||
const DOWNLOAD_TTL_SECONDS = 10 * 60;
|
const DOWNLOAD_TTL_SECONDS = 10 * 60;
|
||||||
const DEFAULT_EXPIRY_DAYS = 7;
|
const DEFAULT_EXPIRY_DAYS = 7;
|
||||||
|
const MAX_EXPIRY_DAYS = 30;
|
||||||
const MAX_METADATA_LEN = 8_192;
|
const MAX_METADATA_LEN = 8_192;
|
||||||
|
const MAX_SIZE_BYTES_FREE = 2 * 1024 * 1024 * 1024;
|
||||||
|
const MAX_MAX_DOWNLOADS = 100;
|
||||||
|
|
||||||
transferRoutes.use("/transfers", rateLimit(30));
|
transferRoutes.use("/transfers", rateLimit(30));
|
||||||
transferRoutes.use("/transfers/*", rateLimit(60));
|
transferRoutes.use("/transfers/*", rateLimit(60));
|
||||||
@ -71,15 +73,10 @@ transferRoutes.post("/transfers", async (c) => {
|
|||||||
return c.json({ error: "invalid_body" }, 400);
|
return c.json({ error: "invalid_body" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const limits = limitsFor(user);
|
|
||||||
|
|
||||||
const sizeBytes = Number(body.sizeBytes);
|
const sizeBytes = Number(body.sizeBytes);
|
||||||
if (!Number.isInteger(sizeBytes) || sizeBytes <= 0) {
|
if (!Number.isInteger(sizeBytes) || sizeBytes <= 0 || sizeBytes > MAX_SIZE_BYTES_FREE) {
|
||||||
return c.json({ error: "invalid_size" }, 400);
|
return c.json({ error: "invalid_size" }, 400);
|
||||||
}
|
}
|
||||||
if (sizeBytes > limits.maxSizeBytes) {
|
|
||||||
return c.json({ error: "plan_limit_size", limit: limits.maxSizeBytes }, 413);
|
|
||||||
}
|
|
||||||
|
|
||||||
const encryptedMetadata = typeof body.encryptedMetadata === "string" ? body.encryptedMetadata : "";
|
const encryptedMetadata = typeof body.encryptedMetadata === "string" ? body.encryptedMetadata : "";
|
||||||
if (!encryptedMetadata || encryptedMetadata.length > MAX_METADATA_LEN) {
|
if (!encryptedMetadata || encryptedMetadata.length > MAX_METADATA_LEN) {
|
||||||
@ -87,12 +84,12 @@ transferRoutes.post("/transfers", async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxDownloads = Number.isInteger(body.maxDownloads)
|
const maxDownloads = Number.isInteger(body.maxDownloads)
|
||||||
? Math.max(1, Math.min(limits.maxDownloads, body.maxDownloads))
|
? Math.max(1, Math.min(MAX_MAX_DOWNLOADS, body.maxDownloads))
|
||||||
: 1;
|
: 1;
|
||||||
|
|
||||||
const expiresInDays = Number.isInteger(body.expiresInDays)
|
const expiresInDays = Number.isInteger(body.expiresInDays)
|
||||||
? Math.max(1, Math.min(limits.maxExpiryDays, body.expiresInDays))
|
? Math.max(1, Math.min(MAX_EXPIRY_DAYS, body.expiresInDays))
|
||||||
: Math.min(DEFAULT_EXPIRY_DAYS, limits.maxExpiryDays);
|
: DEFAULT_EXPIRY_DAYS;
|
||||||
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
let recipientUserId: string | null = null;
|
let recipientUserId: string | null = null;
|
||||||
|
|||||||
@ -1,89 +0,0 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type Stripe from "stripe";
|
|
||||||
import { db } from "../db/client.js";
|
|
||||||
import { users } from "../db/schema.js";
|
|
||||||
import { getStripe, isStripeConfigured, webhookSecret } from "../lib/stripe.js";
|
|
||||||
|
|
||||||
export const webhookRoutes = new Hono();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/stripe/webhook
|
|
||||||
* Verified via the `stripe-signature` header. Keeps the `users` row in sync
|
|
||||||
* with the subscription lifecycle so plan enforcement stays truthful without
|
|
||||||
* extra round-trips to Stripe on every request.
|
|
||||||
*/
|
|
||||||
webhookRoutes.post("/stripe/webhook", async (c) => {
|
|
||||||
if (!isStripeConfigured()) return c.json({ error: "not_configured" }, 503);
|
|
||||||
|
|
||||||
const sig = c.req.header("stripe-signature");
|
|
||||||
if (!sig) return c.json({ error: "missing_signature" }, 400);
|
|
||||||
|
|
||||||
const raw = await c.req.text();
|
|
||||||
let event: Stripe.Event;
|
|
||||||
try {
|
|
||||||
event = getStripe().webhooks.constructEvent(raw, sig, webhookSecret());
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[stripe] webhook signature verification failed:", err);
|
|
||||||
return c.json({ error: "invalid_signature" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (event.type) {
|
|
||||||
case "checkout.session.completed": {
|
|
||||||
const session = event.data.object as Stripe.Checkout.Session;
|
|
||||||
const userId = session.client_reference_id;
|
|
||||||
const subscriptionId = typeof session.subscription === "string" ? session.subscription : null;
|
|
||||||
if (!userId || !subscriptionId) break;
|
|
||||||
const subscription = await getStripe().subscriptions.retrieve(subscriptionId);
|
|
||||||
await applySubscription(userId, subscription);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "customer.subscription.updated":
|
|
||||||
case "customer.subscription.deleted": {
|
|
||||||
const subscription = event.data.object as Stripe.Subscription;
|
|
||||||
const customerId = typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id;
|
|
||||||
const [row] = await db
|
|
||||||
.select({ id: users.id })
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.stripeCustomerId, customerId))
|
|
||||||
.limit(1);
|
|
||||||
if (!row) break;
|
|
||||||
await applySubscription(row.id, subscription);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Other events (invoice.paid, payment_method.attached, etc.) are
|
|
||||||
// acknowledged but not acted on here. The subscription.* events
|
|
||||||
// above are the source of truth for plan state.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[stripe] webhook handler failed:", event.type, err);
|
|
||||||
return c.json({ error: "handler_failed" }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ received: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
async function applySubscription(userId: string, sub: Stripe.Subscription): Promise<void> {
|
|
||||||
const active = sub.status === "active" || sub.status === "trialing";
|
|
||||||
const plan = active ? "pro" : "free";
|
|
||||||
// current_period_end lives on the subscription item in the 2026-03-25 API.
|
|
||||||
// Fall back to the subscription's own timestamp on older shapes just in case.
|
|
||||||
const periodEnd =
|
|
||||||
sub.items.data[0]?.current_period_end ??
|
|
||||||
(sub as unknown as { current_period_end?: number }).current_period_end ??
|
|
||||||
0;
|
|
||||||
const planExpiresAt = periodEnd ? new Date(periodEnd * 1000) : null;
|
|
||||||
await db
|
|
||||||
.update(users)
|
|
||||||
.set({
|
|
||||||
plan,
|
|
||||||
planStatus: sub.status,
|
|
||||||
planExpiresAt,
|
|
||||||
stripeSubscriptionId: sub.id,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(users.id, userId));
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
import type { User } from "../db/schema.js";
|
|
||||||
|
|
||||||
export type PlanId = "free" | "pro";
|
|
||||||
|
|
||||||
export interface PlanLimits {
|
|
||||||
maxSizeBytes: number;
|
|
||||||
maxExpiryDays: number;
|
|
||||||
maxDownloads: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GB = 1024 * 1024 * 1024;
|
|
||||||
|
|
||||||
export const PLAN_LIMITS: Record<PlanId, PlanLimits> = {
|
|
||||||
free: {
|
|
||||||
maxSizeBytes: 2 * GB,
|
|
||||||
maxExpiryDays: 7,
|
|
||||||
maxDownloads: 1,
|
|
||||||
},
|
|
||||||
pro: {
|
|
||||||
maxSizeBytes: 20 * GB,
|
|
||||||
maxExpiryDays: 90,
|
|
||||||
maxDownloads: 100,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the plan the user currently has active. A paid subscription is
|
|
||||||
* honored until `planExpiresAt`, even if `plan_status` has flipped to
|
|
||||||
* "canceled" — that's the grace window until the billing period ends.
|
|
||||||
*/
|
|
||||||
export function getEffectivePlan(user: Pick<User, "plan" | "planExpiresAt"> | null | undefined): PlanId {
|
|
||||||
if (!user) return "free";
|
|
||||||
if (user.plan !== "pro") return "free";
|
|
||||||
if (user.planExpiresAt && user.planExpiresAt.getTime() < Date.now()) return "free";
|
|
||||||
return "pro";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function limitsFor(user: Pick<User, "plan" | "planExpiresAt"> | null | undefined): PlanLimits {
|
|
||||||
return PLAN_LIMITS[getEffectivePlan(user)];
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
import Stripe from "stripe";
|
|
||||||
|
|
||||||
let client: Stripe | null = null;
|
|
||||||
|
|
||||||
export function getStripe(): Stripe {
|
|
||||||
if (client) return client;
|
|
||||||
const key = process.env.STRIPE_SECRET_KEY;
|
|
||||||
if (!key) throw new Error("STRIPE_SECRET_KEY is not set");
|
|
||||||
client = new Stripe(key, { apiVersion: "2026-03-25.dahlia" });
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isStripeConfigured(): boolean {
|
|
||||||
return !!process.env.STRIPE_SECRET_KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function priceIdFor(interval: "monthly" | "yearly"): string {
|
|
||||||
const id = interval === "monthly"
|
|
||||||
? process.env.STRIPE_PRICE_MONTHLY
|
|
||||||
: process.env.STRIPE_PRICE_YEARLY;
|
|
||||||
if (!id) throw new Error(`Missing STRIPE_PRICE_${interval.toUpperCase()} env var`);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function webhookSecret(): string {
|
|
||||||
const s = process.env.STRIPE_WEBHOOK_SECRET;
|
|
||||||
if (!s) throw new Error("STRIPE_WEBHOOK_SECRET is not set");
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
@ -3,7 +3,6 @@ import Home from "./pages/Home";
|
|||||||
import JoinRoom from "./pages/JoinRoom";
|
import JoinRoom from "./pages/JoinRoom";
|
||||||
import Share from "./pages/Share";
|
import Share from "./pages/Share";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
import Pricing from "./pages/Pricing";
|
|
||||||
import Receive from "./pages/Receive";
|
import Receive from "./pages/Receive";
|
||||||
import Inbox from "./pages/Inbox";
|
import Inbox from "./pages/Inbox";
|
||||||
|
|
||||||
@ -13,7 +12,6 @@ export default function App() {
|
|||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/share" element={<Share />} />
|
<Route path="/share" element={<Share />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/pricing" element={<Pricing />} />
|
|
||||||
<Route path="/inbox" element={<Inbox />} />
|
<Route path="/inbox" element={<Inbox />} />
|
||||||
<Route path="/r/:id" element={<Receive />} />
|
<Route path="/r/:id" element={<Receive />} />
|
||||||
<Route path="/:code" element={<JoinRoom />} />
|
<Route path="/:code" element={<JoinRoom />} />
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
export interface ApiUser {
|
export interface ApiUser {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
plan: "free" | "pro";
|
plan: string;
|
||||||
planStatus: string | null;
|
|
||||||
planExpiresAt: string | null;
|
|
||||||
hasStripeCustomer: boolean;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,26 +162,3 @@ export async function claimTransfer(id: string): Promise<boolean> {
|
|||||||
const body = (await res.json()) as { claimed: boolean };
|
const body = (await res.json()) as { claimed: boolean };
|
||||||
return body.claimed;
|
return body.claimed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startCheckout(interval: "monthly" | "yearly"): Promise<string> {
|
|
||||||
const res = await call("/api/billing/checkout", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ interval }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
throw new Error((body as { error?: string }).error ?? `checkout failed: ${res.status}`);
|
|
||||||
}
|
|
||||||
const { url } = (await res.json()) as { url: string };
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openBillingPortal(): Promise<string> {
|
|
||||||
const res = await call("/api/billing/portal", { method: "POST" });
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
throw new Error((body as { error?: string }).error ?? `portal failed: ${res.status}`);
|
|
||||||
}
|
|
||||||
const { url } = (await res.json()) as { url: string };
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -147,14 +147,9 @@ function HomeConnected() {
|
|||||||
|
|
||||||
<footer className="pt-6 mt-10 sm:pt-8 sm:mt-14 rule flex items-center justify-between text-xs text-ink-muted">
|
<footer className="pt-6 mt-10 sm:pt-8 sm:mt-14 rule flex items-center justify-between text-xs text-ink-muted">
|
||||||
<span>Nothing transits the server</span>
|
<span>Nothing transits the server</span>
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Link to="/pricing" className="text-ink-muted hover:text-ink transition-colors duration-fast">
|
|
||||||
Pricing →
|
|
||||||
</Link>
|
|
||||||
<Link to="/settings" className="text-ink hover:text-signal transition-colors duration-fast">
|
<Link to="/settings" className="text-ink hover:text-signal transition-colors duration-fast">
|
||||||
Account →
|
Account →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,206 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
|
||||||
import { useAuthStore } from "../stores/useAuthStore";
|
|
||||||
import { startCheckout } from "../lib/api";
|
|
||||||
|
|
||||||
const FREE_FEATURES = [
|
|
||||||
"Peer-to-peer transfers, LAN or link",
|
|
||||||
"Cloud relay up to 2 GB",
|
|
||||||
"Links expire within 7 days",
|
|
||||||
"1 download per link",
|
|
||||||
"Password protection",
|
|
||||||
];
|
|
||||||
|
|
||||||
const PRO_FEATURES = [
|
|
||||||
"Everything in Free",
|
|
||||||
"Cloud relay up to 20 GB",
|
|
||||||
"Links last up to 90 days",
|
|
||||||
"Up to 100 downloads per link",
|
|
||||||
"Priority support",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Pricing() {
|
|
||||||
const user = useAuthStore((s) => s.user);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [params] = useSearchParams();
|
|
||||||
const canceled = params.get("checkout") === "canceled";
|
|
||||||
|
|
||||||
const [interval, setInterval] = useState<"monthly" | "yearly">("monthly");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleUpgrade = async () => {
|
|
||||||
if (!user) {
|
|
||||||
navigate("/settings");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const url = await startCheckout(interval);
|
|
||||||
window.location.assign(url);
|
|
||||||
} catch (err) {
|
|
||||||
setLoading(false);
|
|
||||||
setError(err instanceof Error ? err.message : "unknown_error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isPro = user?.plan === "pro";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<div className="max-w-2xl mx-auto px-5 sm:px-8 pt-10 pb-24">
|
|
||||||
<header className="flex items-center justify-between pb-8 mb-10 rule">
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="text-sm text-ink-muted hover:text-ink transition-colors duration-fast"
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</Link>
|
|
||||||
<h1 className="font-display text-xl text-ink tracking-tight">Pricing</h1>
|
|
||||||
<div className="w-10" />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{canceled && (
|
|
||||||
<div className="mb-8 px-4 py-3 border border-paper-edge bg-paper-deep rounded-sm text-sm text-ink-muted">
|
|
||||||
Checkout canceled. No charge was made.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-10">
|
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
|
||||||
Plans
|
|
||||||
</div>
|
|
||||||
<h2 className="font-display text-3xl text-ink mt-2 tracking-tight">
|
|
||||||
Simple pricing, fair defaults
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-ink-muted mt-3 leading-relaxed max-w-md">
|
|
||||||
Free forever for peer-to-peer and small cloud transfers. Pro unlocks
|
|
||||||
larger files, longer expiry, and multiple downloads.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="inline-flex border border-paper-edge rounded-sm overflow-hidden mb-8">
|
|
||||||
<button
|
|
||||||
onClick={() => setInterval("monthly")}
|
|
||||||
className={`px-4 py-2 text-xs uppercase tracking-[0.15em] transition-colors
|
|
||||||
${interval === "monthly" ? "bg-ink text-paper" : "bg-paper text-ink-muted hover:text-ink"}`}
|
|
||||||
>
|
|
||||||
Monthly
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setInterval("yearly")}
|
|
||||||
className={`px-4 py-2 text-xs uppercase tracking-[0.15em] transition-colors
|
|
||||||
${interval === "yearly" ? "bg-ink text-paper" : "bg-paper text-ink-muted hover:text-ink"}`}
|
|
||||||
>
|
|
||||||
Yearly <span className="text-signal ml-1">−30%</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 gap-5">
|
|
||||||
<PlanCard
|
|
||||||
name="Free"
|
|
||||||
priceLabel="€0"
|
|
||||||
tagline="For every device, always"
|
|
||||||
features={FREE_FEATURES}
|
|
||||||
action={
|
|
||||||
<div className="text-xs text-ink-muted font-mono uppercase tracking-widest py-3">
|
|
||||||
Current plan if not upgraded
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PlanCard
|
|
||||||
highlighted
|
|
||||||
name="Pro"
|
|
||||||
priceLabel={interval === "monthly" ? "€6" : "€50"}
|
|
||||||
priceSuffix={interval === "monthly" ? "/ month" : "/ year"}
|
|
||||||
tagline="When 2 GB isn't enough"
|
|
||||||
features={PRO_FEATURES}
|
|
||||||
action={
|
|
||||||
isPro ? (
|
|
||||||
<Link
|
|
||||||
to="/settings"
|
|
||||||
className="block w-full text-center py-3 border border-paper-edge bg-paper
|
|
||||||
hover:border-ink text-sm text-ink rounded-sm
|
|
||||||
transition-colors duration-fast ease-crisp"
|
|
||||||
>
|
|
||||||
Manage subscription →
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={handleUpgrade}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full py-3 bg-ink text-paper text-sm font-medium rounded-sm
|
|
||||||
hover:bg-signal transition-colors duration-fast ease-crisp
|
|
||||||
disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{loading ? "Opening checkout…" : user ? "Upgrade to Pro →" : "Sign in to upgrade →"}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="mt-5 text-xs text-fail font-mono">
|
|
||||||
{error === "billing_not_configured"
|
|
||||||
? "Billing is not configured on this instance yet."
|
|
||||||
: error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="mt-10 text-xs text-ink-faint leading-relaxed">
|
|
||||||
Payments are handled by Stripe. Cancel anytime from the customer portal.
|
|
||||||
Your peer-to-peer transfers never transit our servers — your plan affects
|
|
||||||
the optional cloud relay only.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlanCard({
|
|
||||||
name,
|
|
||||||
priceLabel,
|
|
||||||
priceSuffix,
|
|
||||||
tagline,
|
|
||||||
features,
|
|
||||||
action,
|
|
||||||
highlighted,
|
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
priceLabel: string;
|
|
||||||
priceSuffix?: string;
|
|
||||||
tagline: string;
|
|
||||||
features: string[];
|
|
||||||
action: React.ReactNode;
|
|
||||||
highlighted?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`paper-panel p-5 sm:p-6 flex flex-col ${
|
|
||||||
highlighted ? "shadow-lift border-ink" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">{name}</div>
|
|
||||||
<div className="flex items-baseline gap-2 mt-3">
|
|
||||||
<span className="font-display text-4xl text-ink tracking-tight">{priceLabel}</span>
|
|
||||||
{priceSuffix && (
|
|
||||||
<span className="text-xs text-ink-muted font-mono">{priceSuffix}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm text-ink-muted">{tagline}</p>
|
|
||||||
|
|
||||||
<ul className="mt-6 space-y-2.5 flex-1">
|
|
||||||
{features.map((f) => (
|
|
||||||
<li key={f} className="text-sm text-ink flex items-start gap-2">
|
|
||||||
<span className="text-signal mt-1">·</span>
|
|
||||||
<span>{f}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="mt-6">{action}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -5,10 +5,8 @@ import { useProfileStore } from "../stores/useProfileStore";
|
|||||||
import {
|
import {
|
||||||
deleteTransfer,
|
deleteTransfer,
|
||||||
listInboxTransfers,
|
listInboxTransfers,
|
||||||
openBillingPortal,
|
|
||||||
registerDevice,
|
registerDevice,
|
||||||
requestMagicLink,
|
requestMagicLink,
|
||||||
startCheckout,
|
|
||||||
unlinkDevice,
|
unlinkDevice,
|
||||||
type InboxTransfer,
|
type InboxTransfer,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
@ -82,11 +80,15 @@ export default function Settings() {
|
|||||||
<div className="paper-panel px-5 py-4">
|
<div className="paper-panel px-5 py-4">
|
||||||
<div className="text-xs uppercase tracking-[0.15em] text-ink-muted">Email</div>
|
<div className="text-xs uppercase tracking-[0.15em] text-ink-muted">Email</div>
|
||||||
<div className="text-ink mt-1">{user.email}</div>
|
<div className="text-ink mt-1">{user.email}</div>
|
||||||
|
<div className="mt-4 pt-4 border-t border-paper-edge flex items-baseline justify-between">
|
||||||
|
<div className="text-xs uppercase tracking-[0.15em] text-ink-muted">Plan</div>
|
||||||
|
<div className="font-mono text-xs text-ink uppercase tracking-widest">
|
||||||
|
{user.plan}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<PlanSection />
|
|
||||||
|
|
||||||
<ReceivedSection />
|
<ReceivedSection />
|
||||||
|
|
||||||
<SharedLinksSection />
|
<SharedLinksSection />
|
||||||
@ -227,115 +229,6 @@ function SignInForm({ initialError }: { initialError: string | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlanSection() {
|
|
||||||
const user = useAuthStore((s) => s.user);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
if (!user) return null;
|
|
||||||
|
|
||||||
const isPro = user.plan === "pro";
|
|
||||||
const renewLabel = user.planExpiresAt
|
|
||||||
? new Date(user.planExpiresAt).toLocaleDateString()
|
|
||||||
: null;
|
|
||||||
const statusLabel = user.planStatus === "canceled"
|
|
||||||
? "ends"
|
|
||||||
: "renews";
|
|
||||||
|
|
||||||
const handleUpgrade = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const url = await startCheckout("monthly");
|
|
||||||
window.location.assign(url);
|
|
||||||
} catch (err) {
|
|
||||||
setBusy(false);
|
|
||||||
setError(err instanceof Error ? err.message : "unknown");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePortal = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const url = await openBillingPortal();
|
|
||||||
window.location.assign(url);
|
|
||||||
} catch (err) {
|
|
||||||
setBusy(false);
|
|
||||||
setError(err instanceof Error ? err.message : "unknown");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="mb-12">
|
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
|
||||||
Plan
|
|
||||||
</div>
|
|
||||||
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
|
|
||||||
{isPro ? "Pro" : "Free"}
|
|
||||||
</h2>
|
|
||||||
<div className="paper-panel px-5 py-4">
|
|
||||||
{isPro ? (
|
|
||||||
<>
|
|
||||||
<p className="text-sm text-ink">
|
|
||||||
20 GB per transfer · up to 90 days · 100 downloads per link.
|
|
||||||
</p>
|
|
||||||
{renewLabel && (
|
|
||||||
<p className="mt-1 text-xs text-ink-muted font-mono">
|
|
||||||
{statusLabel} {renewLabel}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handlePortal}
|
|
||||||
disabled={busy}
|
|
||||||
className="mt-4 w-full py-2.5 border border-paper-edge hover:border-ink
|
|
||||||
text-sm text-ink rounded-sm transition-colors duration-fast ease-crisp
|
|
||||||
disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{busy ? "Opening portal…" : "Manage subscription →"}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-sm text-ink">
|
|
||||||
2 GB per transfer · 7-day links · 1 download each.
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-ink-muted">
|
|
||||||
Upgrade to send larger files and keep links alive longer.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleUpgrade}
|
|
||||||
disabled={busy}
|
|
||||||
className="flex-1 py-2.5 bg-ink text-paper text-sm font-medium rounded-sm
|
|
||||||
hover:bg-signal transition-colors duration-fast ease-crisp
|
|
||||||
disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{busy ? "Opening checkout…" : "Upgrade to Pro →"}
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
to="/pricing"
|
|
||||||
className="px-4 py-2.5 border border-paper-edge hover:border-ink
|
|
||||||
text-sm text-ink rounded-sm transition-colors duration-fast ease-crisp
|
|
||||||
flex items-center"
|
|
||||||
>
|
|
||||||
Compare
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<p className="mt-3 text-xs text-fail font-mono">
|
|
||||||
{error === "billing_not_configured"
|
|
||||||
? "Billing is not configured on this instance yet."
|
|
||||||
: error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type LinkItem = InboxTransfer & {
|
type LinkItem = InboxTransfer & {
|
||||||
filename: string | null;
|
filename: string | null;
|
||||||
keyFrag: string | null;
|
keyFrag: string | null;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user