From 439c2ae61b5a6423e12da881c509ac97d6add6c7 Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 12 Oct 2026 13:37:50 +0900 Subject: [PATCH] Initial hop. implementation and UGREEN NAS deployment guide --- .dockerignore | 13 + .env.example | 44 + .gitignore | 13 + .prettierignore | 5 + .prettierrc.json | 1 + Dockerfile | 26 + README.md | 92 + SPEC.md | 519 ++ apps/server/drizzle.config.ts | 2 + apps/server/drizzle/0000_aberrant_scourge.sql | 126 + apps/server/drizzle/0001_numerous_dust.sql | 1 + .../drizzle/0002_orange_norrin_radd.sql | 71 + apps/server/drizzle/0003_spooky_blob.sql | 1 + apps/server/drizzle/0004_bright_wildside.sql | 191 + apps/server/drizzle/0005_tan_miracleman.sql | 17 + apps/server/drizzle/0006_file_url_only.sql | 3 + apps/server/drizzle/0007_rapid_morph.sql | 21 + .../drizzle/0008_temporary-retention.sql | 10 + apps/server/drizzle/0009_vault-note.sql | 1 + apps/server/drizzle/0010_single-room.sql | 7 + .../server/drizzle/0011_single-room-index.sql | 1 + apps/server/drizzle/meta/0000_snapshot.json | 894 +++ apps/server/drizzle/meta/0001_snapshot.json | 901 +++ apps/server/drizzle/meta/0002_snapshot.json | 1275 ++++ apps/server/drizzle/meta/0003_snapshot.json | 1282 ++++ apps/server/drizzle/meta/0004_snapshot.json | 1853 +++++ apps/server/drizzle/meta/0005_snapshot.json | 1853 +++++ apps/server/drizzle/meta/0006_snapshot.json | 1853 +++++ apps/server/drizzle/meta/0007_snapshot.json | 2022 +++++ apps/server/drizzle/meta/0008_snapshot.json | 2022 +++++ apps/server/drizzle/meta/0009_snapshot.json | 2029 +++++ apps/server/drizzle/meta/0010_snapshot.json | 2029 +++++ apps/server/drizzle/meta/0011_snapshot.json | 2037 ++++++ apps/server/drizzle/meta/_journal.json | 90 + apps/server/package.json | 32 + apps/server/src/accounts.ts | 667 ++ apps/server/src/app.ts | 828 +++ apps/server/src/auth.ts | 374 + apps/server/src/config.ts | 106 + apps/server/src/db/index.ts | 57 + apps/server/src/db/schema.ts | 354 + apps/server/src/mail.ts | 48 + apps/server/src/main.ts | 13 + apps/server/src/presence.ts | 77 + apps/server/src/security.ts | 84 + apps/server/src/service.ts | 1047 +++ apps/server/src/storage.ts | 596 ++ apps/server/src/vault.js | 141 + apps/server/test/helpers.ts | 149 + apps/server/test/mail.test.ts | 66 + apps/server/test/migrations.test.ts | 74 + apps/server/test/phase1.test.ts | 557 ++ apps/server/test/phase2.test.ts | 411 ++ apps/server/test/phase3.test.ts | 470 ++ apps/server/test/refresh-queue.test.ts | 40 + apps/server/test/retention-migration.test.js | 66 + apps/server/test/rooms.test.js | 90 + apps/server/test/vault.test.js | 409 ++ apps/server/tsconfig.json | 11 + apps/web/index.html | 14 + apps/web/package.json | 22 + apps/web/public/icon.svg | 1 + apps/web/src/AccountPanel.vue | 753 ++ apps/web/src/App.vue | 942 +++ apps/web/src/SendComposer.vue | 426 ++ apps/web/src/client.js | 409 ++ apps/web/src/deliveryReceiver.js | 83 + apps/web/src/main.js | 6 + apps/web/src/refreshQueue.js | 19 + apps/web/src/style.css | 3116 ++++++++ apps/web/src/theme.js | 23 + apps/web/vite.config.js | 15 + compose.yaml | 24 + deploy.md | 202 + docs/IMPLEMENTATION_PLAN.md | 123 + docs/TEMPORARY_VAULT.md | 43 + docs/TRANSFER_UX.md | 37 + docs/VERIFICATION.md | 80 + package-lock.json | 6508 +++++++++++++++++ package.json | 36 + packages/shared/package.json | 15 + packages/shared/src/index.ts | 175 + packages/shared/tsconfig.json | 10 + scripts/configure-nas.sh | 88 + scripts/dev-runtime.mjs | 35 + scripts/dev.mjs | 49 + scripts/seed-test-accounts.mjs | 75 + tests/docker/persistence.mjs | 86 + tests/e2e/device-menu.mjs | 116 + tests/e2e/layout.mjs | 180 + tests/e2e/phase1.mjs | 409 ++ tests/e2e/phase3.mjs | 233 + tsconfig.base.json | 9 + 93 files changed, 42434 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 apps/server/drizzle.config.ts create mode 100644 apps/server/drizzle/0000_aberrant_scourge.sql create mode 100644 apps/server/drizzle/0001_numerous_dust.sql create mode 100644 apps/server/drizzle/0002_orange_norrin_radd.sql create mode 100644 apps/server/drizzle/0003_spooky_blob.sql create mode 100644 apps/server/drizzle/0004_bright_wildside.sql create mode 100644 apps/server/drizzle/0005_tan_miracleman.sql create mode 100644 apps/server/drizzle/0006_file_url_only.sql create mode 100644 apps/server/drizzle/0007_rapid_morph.sql create mode 100644 apps/server/drizzle/0008_temporary-retention.sql create mode 100644 apps/server/drizzle/0009_vault-note.sql create mode 100644 apps/server/drizzle/0010_single-room.sql create mode 100644 apps/server/drizzle/0011_single-room-index.sql create mode 100644 apps/server/drizzle/meta/0000_snapshot.json create mode 100644 apps/server/drizzle/meta/0001_snapshot.json create mode 100644 apps/server/drizzle/meta/0002_snapshot.json create mode 100644 apps/server/drizzle/meta/0003_snapshot.json create mode 100644 apps/server/drizzle/meta/0004_snapshot.json create mode 100644 apps/server/drizzle/meta/0005_snapshot.json create mode 100644 apps/server/drizzle/meta/0006_snapshot.json create mode 100644 apps/server/drizzle/meta/0007_snapshot.json create mode 100644 apps/server/drizzle/meta/0008_snapshot.json create mode 100644 apps/server/drizzle/meta/0009_snapshot.json create mode 100644 apps/server/drizzle/meta/0010_snapshot.json create mode 100644 apps/server/drizzle/meta/0011_snapshot.json create mode 100644 apps/server/drizzle/meta/_journal.json create mode 100644 apps/server/package.json create mode 100644 apps/server/src/accounts.ts create mode 100644 apps/server/src/app.ts create mode 100644 apps/server/src/auth.ts create mode 100644 apps/server/src/config.ts create mode 100644 apps/server/src/db/index.ts create mode 100644 apps/server/src/db/schema.ts create mode 100644 apps/server/src/mail.ts create mode 100644 apps/server/src/main.ts create mode 100644 apps/server/src/presence.ts create mode 100644 apps/server/src/security.ts create mode 100644 apps/server/src/service.ts create mode 100644 apps/server/src/storage.ts create mode 100644 apps/server/src/vault.js create mode 100644 apps/server/test/helpers.ts create mode 100644 apps/server/test/mail.test.ts create mode 100644 apps/server/test/migrations.test.ts create mode 100644 apps/server/test/phase1.test.ts create mode 100644 apps/server/test/phase2.test.ts create mode 100644 apps/server/test/phase3.test.ts create mode 100644 apps/server/test/refresh-queue.test.ts create mode 100644 apps/server/test/retention-migration.test.js create mode 100644 apps/server/test/rooms.test.js create mode 100644 apps/server/test/vault.test.js create mode 100644 apps/server/tsconfig.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/public/icon.svg create mode 100644 apps/web/src/AccountPanel.vue create mode 100644 apps/web/src/App.vue create mode 100644 apps/web/src/SendComposer.vue create mode 100644 apps/web/src/client.js create mode 100644 apps/web/src/deliveryReceiver.js create mode 100644 apps/web/src/main.js create mode 100644 apps/web/src/refreshQueue.js create mode 100644 apps/web/src/style.css create mode 100644 apps/web/src/theme.js create mode 100644 apps/web/vite.config.js create mode 100644 compose.yaml create mode 100644 deploy.md create mode 100644 docs/IMPLEMENTATION_PLAN.md create mode 100644 docs/TEMPORARY_VAULT.md create mode 100644 docs/TRANSFER_UX.md create mode 100644 docs/VERIFICATION.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 scripts/configure-nas.sh create mode 100644 scripts/dev-runtime.mjs create mode 100644 scripts/dev.mjs create mode 100644 scripts/seed-test-accounts.mjs create mode 100644 tests/docker/persistence.mjs create mode 100644 tests/e2e/device-menu.mjs create mode 100644 tests/e2e/layout.mjs create mode 100644 tests/e2e/phase1.mjs create mode 100644 tests/e2e/phase3.mjs create mode 100644 tsconfig.base.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..72b8499 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +**/node_modules +**/dist +.data +.env +.env.* +!.env.example +.git +test-results +playwright-report +tests/e2e/*.png +compose.nas.yaml +backups diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a2d1b38 --- /dev/null +++ b/.env.example @@ -0,0 +1,44 @@ +# 실제 배포 직전에 충돌 없는 포트·바인딩 IP·프록시 경로를 확인해서 입력합니다. +HOP_BIND_IP= +HOP_HOST_PORT= +HOP_CONTAINER_PORT= +APP_URL=https://hop.noname.company +NODE_ENV=production +TZ=Asia/Seoul +SESSION_SECRET= +DISCOVERY_SECRET= +DATABASE_PATH=/data/database/hop.sqlite +UPLOAD_DIR=/data/uploads +# 실제 접속 IP 전달을 검증한 후에만 활성화합니다. +SAME_NETWORK_DISCOVERY_ENABLED=false +TRUSTED_PROXY_CIDRS= +GUEST_SESSION_RETENTION_DAYS=30 +LOCAL_REQUEST_TIMEOUT_SECONDS=60 +ACCEPTED_UPLOAD_START_HOURS=24 +SPACE_RETENTION_MINUTES=10 +SPACE_MAX_DEVICES=10 +OFFLINE_GRACE_MS=3000 +MAX_URL_PAYLOAD_SIZE_KB=1024 +MAX_ITEMS_PER_TRANSFER=20 +DEFAULT_URL_RETENTION_DAYS=7 +GUEST_REQUESTS_PER_MINUTE=5 +IP_REQUESTS_PER_MINUTE=20 +PAIR_REQUESTS_PER_MINUTE=2 +REJECT_COOLDOWN_SECONDS=300 +# Resend 발송 설정과 실제 인증 메일 수신을 확인한 뒤 공개 가입을 엽니다. +REGISTRATION_MODE=closed +EMAIL_VERIFICATION_REQUIRED=true +# Resend에서 noname.company 도메인 Verified와 Sending access 권한을 확인합니다. +RESEND_API_KEY= +MAIL_FROM=hop. +SETUP_TOKEN= +MAX_FILE_SIZE_MB=2048 +USER_STORAGE_LIMIT_MB=10240 +GUEST_STORAGE_LIMIT_MB=2048 +TOTAL_GUEST_STORAGE_LIMIT_MB=20480 +MIN_FREE_DISK_MB=5120 +DEFAULT_FILE_RETENTION_DAYS=7 +EMAIL_REQUEST_RETENTION_DAYS=7 +ORPHAN_UPLOAD_RETENTION_HOURS=24 +DELETE_UNDO_SECONDS=5 +AUDIT_RETENTION_DAYS=30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df6a427 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +.env +.env.* +!.env.example +.data/ +*.sqlite* +playwright-report/ +test-results/ +.DS_Store +tests/e2e/*.png +compose.nas.yaml +backups/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..8772534 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules +dist +package-lock.json +SPEC.md +apps/server/drizzle/meta diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..a930a91 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1 @@ +{"singleQuote":true,"trailingComma":"all","printWidth":100,"semi":true} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..68345d8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM node:22-bookworm-slim AS build +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/* +COPY package.json package-lock.json ./ +COPY apps/server/package.json apps/server/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/shared/package.json packages/shared/package.json +RUN npm ci +COPY . . +RUN npm run build && npm prune --omit=dev + +FROM node:22-bookworm-slim AS runtime +ENV NODE_ENV=production HOST=0.0.0.0 WEB_ROOT=/app/apps/web/dist DATABASE_PATH=/data/database/hop.sqlite UPLOAD_DIR=/data/uploads +WORKDIR /app +COPY --from=build --chown=node:node /app/node_modules ./node_modules +COPY --from=build --chown=node:node /app/package.json ./package.json +COPY --from=build --chown=node:node /app/packages ./packages +COPY --from=build --chown=node:node /app/apps/server/node_modules ./apps/server/node_modules +COPY --from=build --chown=node:node /app/apps/server/package.json ./apps/server/package.json +COPY --from=build --chown=node:node /app/apps/server/dist ./apps/server/dist +COPY --from=build --chown=node:node /app/apps/server/drizzle ./apps/server/drizzle +COPY --from=build --chown=node:node /app/apps/web/dist ./apps/web/dist +RUN mkdir -p /data/database /data/uploads && chown -R node:node /data +USER node +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" +CMD ["node", "apps/server/dist/main.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5719fd --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# hop. + +기기 사이를 가볍게. 가입 없이 접속한 브라우저 사이에서 수락 후 자료를 전달하는 서비스입니다. + +현재 **Phase 1·2 검증 완료 · Phase 3 구현 진행 중**입니다. 파일 전송은 구현되어 있습니다. 로그인 수신 기록·7일 보관함은 구현되어 있으며 Resend API 어댑터의 실제 메일 수신 검증이 남아 있습니다. 관리자·PWA는 후속 단계입니다. 전체 설계는 [구현 계획](docs/IMPLEMENTATION_PLAN.md), 개발 기준은 [SPEC.md](SPEC.md)를 참고하세요. + +## 로컬 개발 + +Node.js 22.12 이상과 npm을 사용합니다. + +```sh +npm ci +npm run build -w @hop/shared +npm run dev +``` + +웹과 API 모두 OS가 빈 임시 포트를 배정합니다. 프로젝트 루트 `.env`를 읽습니다. 개발 DB·파일은 기본 `.data/`를 사용하며 Docker의 `/data/` 설정을 그대로 사용하지 않습니다. 출력된 `개발 화면` 주소를 열어 주세요. 서버 코드 변경은 자동으로 다시 시작하며 실행 중에는 배정된 포트를 유지합니다. 고정 샘플 포트나 기존 서비스 포트를 사용하지 않습니다. 데이터는 `.data/hop.sqlite`에 저장합니다. 동일 브라우저의 탭은 하나의 기기이므로 다른 기기를 테스트하려면 다른 브라우저·프로필·시크릿 창을 사용하세요. + +## 검증 + +```sh +npm run typecheck +npm run build +npm test +``` + +API 테스트는 격리된 실제 SQLite DB와 임시 로컬 포트의 Socket.IO를 사용합니다. 테스트 환경이 로컬 소켓 연결을 제한하면 권한이 필요할 수 있습니다. 운영 프록시와 실기기 확인을 자동 테스트로 대신했다고 주장하지 않습니다. + +## Docker 배포 준비 + +유그린 NAS에서 Docker 앱 버튼으로 배포하는 순서는 [deploy.md](deploy.md)를 따라 진행하세요. 현재 배포 경로는 Cloudflare Tunnel → 기존 NPM(8080) → NAS의 hop.(50189)입니다. + +`.env.example`을 `.env`로 복사하고 비밀값을 설정합니다. `SESSION_SECRET`과 `DISCOVERY_SECRET`은 서로 다른 32자 이상의 무작위 비밀값이어야 합니다. 예를 들어 `openssl rand -hex 32`로 각각 생성합니다. Git에 실제 환경 파일·DB·파일을 넣지 않습니다. + +이번 NAS 배포는 사용자가 지정한 **50189**를 호스트·컨테이너 포트로 사용합니다. `scripts/configure-nas.sh`가 실제 NAS 내부 IP를 물어보고 운영 `.env`와 배포 YAML을 준비합니다. 다른 환경에서는 충돌 없는 포트·바인딩 IP·프록시 경로를 따로 확인하세요. Compose는 필수 값이 비어 있으면 실행을 거절합니다. `.env`는 Node 서버 직접 실행 시 자동 로드되지 않으며 Compose 또는 실행 환경이 주입합니다. + +```sh +docker compose config +docker compose up -d --build +``` + +앱 시작 시 버전 관리된 Drizzle 마이그레이션을 적용하며 실패하면 시작하지 않습니다. DB와 업로드 볼륨은 재배포·재시작 후 유지됩니다. `docker compose down -v`는 데이터를 삭제하므로 사용하지 마세요. + +Nginx Proxy Manager에는 실제 컨테이너/호스트 경로의 포트를 지정하고 HTTPS·WebSocket 지원을 켭니다. Secure 쿠키 때문에 운영 환경에서는 HTTPS가 필수입니다. 요청·연결 타임아웃과 업로드 제한은 Phase 2에서 문서화하고 실제 경로에서 검증합니다. 앱의 파일 상한은 중간 프록시·Cloudflare 상한을 보장하지 않습니다. + +## 발견과 QR·코드 연결 + +같은 네트워크 탐색은 기본 비활성입니다. `TRUSTED_PROXY_CIDRS`는 실제로 앱에 연결하는 검증된 프록시 IP/CIDR만 허용합니다. 임의 전달 헤더를 신뢰하지 않습니다. 검증된 클라이언트 IP를 얻지 못하면 네트워크 탐색을 하지 않습니다. 공인 IP가 정확히 같은 경우만 후보로 표시하고 IPv6 /64 통합은 하지 않습니다. 같은 공인 IP는 동일 Wi-Fi나 실제 근접의 증명이 아닙니다. + +QR·8자리 코드는 기본 10분의 임시 공간 초대입니다. 최대 10대, 선택적 참여 승인, 내보내기와 공간 닫기를 지원합니다. 공간 참여와 자료 전송 수락은 별개입니다. QR 초대 토큰은 URL fragment에 넣어 서버 요청·리퍼러로 전달하지 않으며 참여 후 주소에서 지웁니다. + +## 익명 자료 + +비로그인 화면에는 전송 내역을 표시하지 않습니다. 수락 후 링크는 새 탭으로 열고 파일은 다운로드합니다. 익명 기기 세션은 기본 30일이며 저장한 자료는 최대 7일입니다. 쿠키 삭제·만료·초기화 시 이전 익명 접근권은 복구할 수 없습니다. 로그인 전 수신 자료를 계정으로 가져오지 않습니다. 기기 세션 정리가 필요하면 브라우저의 사이트 데이터를 삭제합니다. 기기 이름은 인증된 신원이 아니며 전송은 서버를 경유합니다. E2EE나 P2P 전송을 제공한다고 안내하지 않습니다. + +## 업데이트와 백업 + +업데이트 전 앱을 정지하고 DB 볼륨(관련 WAL·SHM 포함)과 업로드 볼륨을 같은 시점에 백업합니다. SQLite 파일만 실행 중에 임의 복사하지 마세요. 복원은 별도 볼륨에서 DB integrity check와 실제 자료 접근을 확인한 뒤 적용합니다. 실제 Docker 백업·복원 검증은 Phase 5에서 수행합니다. + +파일당 기본 2GiB, 익명 주체당 2GiB, 전체 익명 20GiB 제한과 최소 5GiB 여유 공간을 적용합니다. MB/KB 환경변수는 MiB/KiB 기준입니다. 파일은 수락 후 스트리밍 저장하고 중단 시 임시 파일·예약을 회수합니다. 초기에는 업로드 재개나 백그라운드 완료를 보장하지 않습니다. + +## 개발 환경의 QR + +기본 개발 URL은 `127.0.0.1`입니다. 이 URL을 휴대폰에서 열면 휴대폰 자신을 가리키므로 PC 개발 서버에 접속할 수 없습니다. 같은 PC의 다른 브라우저·시크릿 창에서는 코드를 입력해 연결할 수 있습니다. 휴대폰 검증을 시작할 때 사용할 LAN 접속 주소·포트·바인딩 범위를 사용자에게 확인합니다. HTTPS 운영 배포에서는 공개 서비스 주소로 QR을 생성합니다. 개발 HTTP 환경에서는 플랫폼에 따라 클립보드 등 보안 컨텍스트 API가 제한될 수 있습니다. + +## 이메일 발송 + +2026-10-08 사용자 지침에 따라 명세의 SMTP 대신 **기존 Resend 연동**을 사용합니다. 발신 주소는 `hello@noname.company`입니다. Resend HTTPS API 발송 어댑터를 연결했습니다. 루트 `.env`의 `RESEND_API_KEY`와 `MAIL_FROM`을 서버에서 읽으며 API 키는 브라우저로 전달하지 않습니다. 공개 가입은 기본 닫혀 있으며, `REGISTRATION_MODE=open`으로 시작하려면 사용 가능한 메일 어댑터가 필요합니다. API 키는 Git에 저장하지 않습니다. + +계정 메일 토큰은 일회용·해시 저장이며 링크 fragment를 사용합니다. 인증 전 계정 업로드·원격 전송·수락을 금지합니다. 이메일 정규화는 양끝 공백 제거와 도메인 소문자화이며 로컬 파트 대소문자·점·별칭은 보존합니다. ‘내 계정에 저장’은 기존 익명 접근권을 제거하고 계정 보관함으로 이동하며 원래 만료와 상대 접근권을 유지합니다. + +최초 관리자 초기화는 `SETUP_TOKEN`(32자 이상 무작위 비밀값)과 빈 사용자 DB 조건으로만 가능합니다. `/api/auth/setup`도 이메일 인증을 거치며, 실제 메일 수신 검증 후 실행 절차를 완성합니다. + +기술 참고: [Fastify 프록시 설정](https://fastify.dev/docs/latest/Reference/Server/), [Drizzle SQLite](https://orm.drizzle.team/docs/sqlite/get-started-sqlite), [Tailwind Vite](https://tailwindcss.com/docs/installation/using-vite). + +## 현재 전송 UX + +[파일·링크 통합 수정안](docs/TRANSFER_UX.md)을 우선합니다. 일반 텍스트 전송과 유형 탭은 제거했습니다. 기기를 누르면 클립보드 URL을 제안하며, 권한 거부나 일반 텍스트는 무시하고 파일 선택을 유지합니다. 링크 직접 붙여넣기도 가능합니다. 읽기만으로 전송하지 않으며 수락 후에만 전달합니다. 수신과 완료 카드는 뷰포트 상단에 표시됩니다. 기본 테마는 검정이며 라이트·시스템 전환을 유지합니다. + +## 개발 테스트 계정 + +`node --import tsx scripts/seed-test-accounts.mjs`로 개발 DB에 인증된 일반 사용자 `test1@hop.test`, `test2@hop.test`를 준비합니다. 비밀번호는 둘 다 `1234`입니다. 메일을 보내지 않으며 운영 환경에서는 실행을 거절합니다. 같은 이름의 테스트 계정은 비밀번호만 재설정하고 다른 기존 계정은 변경하지 않습니다. + +개발·테스트 환경은 1자 이상 비밀번호를 허용합니다. 운영 환경(`NODE_ENV=production`)은 기존 12자 이상 규칙을 유지하며 가입·재설정 화면도 서버가 제공한 최소 길이를 적용합니다. + +## 현재 전송·보관함 흐름 + +최신 동작 기준은 [TEMPORARY_VAULT.md](docs/TEMPORARY_VAULT.md)입니다. 중앙에서 기기를 고른 뒤 PC 모달·모바일 하단 시트로 링크·파일·임시 저장 자료를 보냅니다. 로그인 중 받은 자료는 보관함의 받은 항목에 날짜별로 남고, 직접 담은 자료는 임시 저장에서 한 줄 메모와 함께 관리합니다. 모든 자료는 최대 7일입니다. 저장 종류는 먼저 링크/파일 중 하나를 선택합니다. 이메일 원격 전송과 영구 즐겨찾기는 제거했습니다. + +개발 실행은 같은 PC의 다른 브라우저를 자동 발견하고 반복 검증용 요청 한도를 사용합니다. QR 방에 들어가면 방 안의 기기만 보이며 한 방만 참여할 수 있습니다. 방장 나가기는 방 전체를 닫습니다. 운영 발견은 실제 프록시 경로를 검증한 후 켭니다. + +`npm run test:e2e:layout`은 실제 12개 기기 연결과 4/3/2열, 선택 전 입력 없음, 중앙 모달/하단 시트 및 방 격리를 검증합니다. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..e04d3bc --- /dev/null +++ b/SPEC.md @@ -0,0 +1,519 @@ +# hop. 프로젝트 개발 명세서 + +> 2026-10-08 사용자 수정안: 신규 전송은 파일·HTTP/HTTPS URL만 지원하고 일반 텍스트 전송과 유형 탭을 제거한다. 기기 선택 시 클립보드 URL 읽기를 시도하는 통합 전송 UX는 [TRANSFER_UX.md](docs/TRANSFER_UX.md)를 우선 적용한다. 이메일 발송은 SMTP 대신 Resend(`hello@noname.company`)를 사용한다. 기본 테마는 검정이다. + +> 2026-10-12 추가 수정안: 명시적으로 수락한 전송은 링크 자동 새 탭 열기·파일 자동 다운로드로 완료하고 별도 도착 확인 팝업을 제거한다. 비로그인 전송 내역 UI를 제거하며 취소는 전송 버튼 자리에서 처리한다. 수락 요청은 중앙 고대비 팝업으로 표시한다. 프런트엔드는 Vue + JavaScript로 전환한다. 개발·테스트 환경은 1자 이상 비밀번호를 허용하고 운영은 12자 이상 규칙을 유지한다. + +> 2026-10-12 최종 화면·보관 구조 수정: [TEMPORARY_VAULT.md](docs/TEMPORARY_VAULT.md)를 우선 적용한다. 메인은 중앙 기기 선택과 선택 후 전송 모달/하단 시트로 구성하며, 보관함 안에 임시 저장·날짜별 받은 항목을 둔다. 로그인 중 수신만 계정에 기록한다. 모든 저장은 최대 7일이고 새 이메일 원격 전송·영구 즐겨찾기·익명 사후 가져오기를 제거한다. 한 기기는 한 방에만 속하며 방 참여자는 주변 목록과 격리한다. 방장이 나가면 모두 나간다. + +> 2026-10-12 최종 레이아웃·콘텐츠 선택 수정: QR은 기기 카드 영역에 함께 배치하고 내 기기는 푸터의 작은 상태/설정 버튼으로 옮긴다. 전송 모달은 링크·파일·로그인 보관함 중 현재 콘텐츠 하나와 전송 버튼 하나만 표시하며 선택 방법에 따라 크기를 변경하지 않는다. 보관함 항목 선택만으로 전송하지 않는다. + +버전: 3.0 +서비스명: hop. +슬로건: 기기 사이를 가볍게. +서비스 주소: https://hop.noname.company +배포 환경: UGREEN DXP6800PRO / Docker Compose + +이 문서는 이전 명세를 대체하는 독립적인 개발 기준이다. 이전 버전의 계정 중심 홈이나 기기별 영구 수신함을 함께 구현하지 않는다. + +## 1. 목적과 제품 방향 + +hop.은 가입 없이 접속한 기기 사이에서 파일, 텍스트, URL을 간단히 전달하는 웹서비스다. 홈 화면은 현재 연결 가능한 기기를 보여주고, 기기를 선택해 전송 요청을 보내며, 상대가 수락하면 전달한다. + +멀리 있거나 사이트에 접속하지 않은 사람에게는 이메일 주소로 전달한다. 이 보조 기능은 이메일 인증 계정을 사용하고 서버에 저장해 나중에 확인할 수 있게 한다. 자기 이메일로 저장하면 다른 기기에서 꺼내 쓸 수 있다. + +최우선 목표는 누구나 가볍게 사용할 수 있는 것이다. 핵심은 ‘접속 → 대상 선택 → 수락 → 전달’이다. 복잡한 폴더 관리나 클라우드 드라이브 기능을 중심에 두지 않는다. 기기 이름 변경을 지원하며 계정 가입을 홈 진입 조건으로 만들지 않는다. + +## 2. 두 가지 전송 방식 + +| 항목 | 즉석 전송 | 이메일 전송 | +|---|---|---| +| 목적 | 현재 접속한 기기로 바로 전달 | 원격 또는 오프라인 회원에게 전달 | +| 회원가입 | 불필요 | 송신자와 수신자 모두 인증 계정 필요 | +| 대상 | 같은 네트워크의 접속 기기 또는 QR·코드로 연결한 기기 | 인증된 이메일 주소 | +| 수락 | 매 전송 수락 | 낯선 발신자는 요청 수락, 허용 발신자는 자동 수신 | +| 수신 범위 | 선택한 브라우저 기기 | 계정의 모든 로그인 기기에서 공유 | +| 오프라인 대상 | 전송 불가 | 서버에 저장 가능 | +| 보관 | 익명 브라우저에서 유효기간 동안 접근 | 계정 보관함에서 접근 | + +초기 파일 전송은 NAS 서버를 경유한다. 즉석 전송은 즉시 전달하는 UX를 뜻하며 P2P나 서버 미저장을 뜻하지 않는다. WebRTC P2P는 초기 범위에서 제외한다. + +## 3. 접속 기기와 이름 + +### 3.1 익명 기기 세션 + +접속 시 서버가 임의 기기 식별자와 익명 세션을 발급한다. 브라우저 지문은 수집하지 않는다. 공개 기기 식별자는 인증 토큰과 분리한다. 인증 비밀값은 HttpOnly 쿠키로 관리하고 클라이언트가 제출한 기기 ID만으로 권한을 인정하지 않는다. + +브라우저 또는 프로필을 하나의 기기로 취급한다. 동일 브라우저의 여러 탭은 하나로 합치고 모든 연결이 종료된 뒤 유예 시간 후 오프라인 처리한다. 다른 브라우저나 프로필은 별도 기기다. 초기 익명 세션의 서버 유효기간은 30일이며 쿠키 삭제·만료 시 이전 익명 수신함을 복구할 수 없음을 안내한다. + +### 3.2 기기 이름 변경 + +첫 방문에는 플랫폼 기반 기본 이름을 제안하고 언제든 수정할 수 있다. 예: Mac Studio, Galaxy S25 Ultra, iPad Pro, Nothing Phone. 이름은 1~40자이며 제어문자와 HTML 실행을 허용하지 않는다. 변경은 재접속 후 유지하고 연결된 상대의 목록에도 반영한다. + +이름은 중복 가능하므로 서버가 생성한 짧은 구분 코드를 함께 표시한다. 사용자가 이름을 ‘관리자’나 특정인으로 지정해도 신원이 인증됐다고 표현하지 않는다. 이메일 계정과 기기 이름은 별개다. 로그인·로그아웃으로 기기 이름은 바뀌지 않는다. 익명 표시에는 이메일을 노출하지 않는다. + +### 3.3 발견 범위 + +전체 사이트 접속자를 보여주지 않는다. 다음 범위의 기기만 목록에 표시한다. + +1. 같은 네트워크: 서버가 검증한 클라이언트 공인 IP가 정확히 같은 온라인 기기 후보. +2. 연결 공간: QR 또는 짧은 코드로 같은 임시 공간에 참여한 온라인 기기. + +같은 공인 IP는 물리적 근접이나 동일 Wi-Fi의 확실한 증거가 아니다. 회사 공유망이나 통신사 공유 IP에서 예상보다 넓은 범위가 표시될 수 있다. UI는 ‘주변’이라는 상위 개념 아래 해당 목록을 ‘같은 네트워크’로 명시하고 실제 거리나 Bluetooth 탐색이라고 표시하지 않는다. IPv6 주소의 임의 /64 통합은 하지 않는다. 같은 네트워크 탐색은 운영자가 끌 수 있으며 이때 QR·코드는 계속 제공한다. + +기본 수신 대기는 켜져 있으며 기기 이름 옆에 ‘발견 허용’ 스위치를 제공한다. 끄면 자동 목록에서 숨고 새 즉석 요청도 받지 않는다. 기존 수락 전송은 계속 진행할 수 있다. 목록 노출 자체가 민감한 환경을 위해 설정 상태를 눈에 띄게 표시한다. + +IP는 브라우저에 공개하지 않는다. 그룹 키는 서버 비밀값으로 HMAC 처리하고 접속 중에만 사용한다. 이름 조회·기기 목록 이벤트는 서버가 발견 범위를 검증한 세션에만 보낸다. 대상 ID를 알아도 다른 범위 기기로 직접 요청할 수 없다. + +### 3.4 QR와 코드 연결 + +사용자가 임시 연결 공간을 만들면 QR와 사람이 입력할 짧은 코드를 제공한다. QR는 충분한 엔트로피의 초대 토큰을 사용하고 코드는 예측·대입 방지를 위해 발급·조회 횟수를 제한한다. 기본 공간 유효기간은 10분, 최대 참여 기기는 10대다. + +다른 네트워크의 기기도 참여할 수 있다. 기본은 QR·코드를 가진 기기가 즉시 참여하며 생성자에게 참여 사실과 이름을 알린다. 생성자가 ‘참여 승인 필요’를 켠 공간에서는 이름을 확인한 뒤 승인한다. 코드 자체를 참여 허가로 취급하지만 파일 전송 수락은 생략하지 않는다. 공간에 들어온 이후에도 파일 전송은 별도 수락을 받는다. 생성자는 참여자를 내보내거나 공간을 닫을 수 있다. 만료·퇴장 이후 새 요청은 금지하지만 이미 수락한 전송과 수신 자료의 접근권은 자체 유효기간에 따라 유지한다. QR·코드는 다운로드 링크가 아니다. + +## 4. 즉석 전송 흐름 + +1. 접속 후 자신의 기기 이름과 발견 허용 상태를 확인한다. +2. 같은 네트워크 또는 연결 공간에서 온라인 대상 기기를 선택한다. +3. 텍스트·URL을 입력하거나 파일을 선택한다. +4. 서버에 전송 요청의 메타데이터를 보낸다. 수락 전 파일 바이트를 업로드하지 않는다. +5. 수신 기기에 발신 기기 이름·구분 코드·유형·파일명·크기·개수와 수락·거절 버튼을 표시한다. +6. 수락 후 서버가 업로드 권한과 용량을 예약하고 송신자가 업로드한다. 텍스트·URL도 수락 후 전달한다. +7. 저장이 확정되면 수신 기기에 복사·링크 열기·다운로드 동작을 제공한다. +8. 완료 후 양쪽의 이번 기기 전송 목록에 결과를 표시한다. + +요청 기본 유효기간은 60초다. 거절·만료·오프라인 대상에는 업로드를 시작하지 않는다. 수락 직전 대상 온라인·수신 상태를 다시 검사한다. 수락 후 잠시 연결이 끊겨도 완료된 자료는 유효한 익명 세션으로 다시 접속해 확인할 수 있다. 업로드는 초기 버전에서 중단 지점 재개를 보장하지 않으며 재시도 시 새 업로드로 처리한다. + +자동 다운로드, 링크 자동 열기, 전체 화면 요청 팝업은 사용하지 않는다. 수락 전 텍스트 본문이나 이미지 미리보기를 자동 노출하지 않는다. 요청 내용을 악성 HTML로 실행하지 않는다. 익명 기기 이름은 신뢰할 수 있는 신원 증명이 아니므로 수신 카드에 필요한 구분 정보를 제공한다. + +## 5. 이메일 계정과 인증 + +### 5.1 회원가입과 로그인 + +이메일이 로그인 ID이자 원격 전송 주소다. 별도 username이나 @아이디는 만들지 않는다. 가입 필드는 이메일, 비밀번호, 비밀번호 확인, 표시 이름이다. 비밀번호는 최소 12자, 긴 비밀번호와 붙여넣기를 허용하고 Argon2id로 해싱한다. + +가입 후 이메일 인증을 필수로 한다. 인증 전 원격 전송·요청 수락·계정 파일 업로드를 금지한다. 인증 메일 재발급, 비밀번호 찾기·재설정, 비밀번호·이메일 변경, 로그아웃, 다른 세션 전체 로그아웃을 지원한다. 이메일 변경은 새 주소 인증 후 확정하고 이전 주소로 안내한다. + +메일은 SMTP로 발송하고 인증·복구 토큰은 일회용, 유효기간 제한, 해시 저장을 적용한다. 가입·복구 응답은 계정 존재 여부를 불필요하게 노출하지 않는다. 이메일 정규화는 도메인 소문자화 등 명시된 보수적 규칙을 사용하며 제공자별 점·별칭 제거를 임의 적용하지 않는다. + +세션은 서버 기반이며 쿠키는 HttpOnly, Secure, SameSite=Lax를 적용한다. 인증 성공 시 세션을 회전하여 고정 공격을 막는다. 비밀번호 재설정·정지·탈퇴 시 기존 세션과 Socket.IO 권한을 무효화한다. 익명 세션과 계정 세션은 분리한다. + +### 5.2 익명 수신과 로그인 + +익명으로 받은 자료를 로그인 시 자동으로 계정에 합치지 않는다. 사용자에게 ‘내 계정에 저장’ 동작을 제공하고 명시적으로 선택한 자료만 해당 계정 접근권과 용량으로 옮긴다. 원래 만료 시각은 유지한다. + +같은 브라우저에서 계정을 바꾸거나 로그아웃해도 익명 기기 수신 자료가 남을 수 있다는 점을 명확히 안내한다. 공유 기기에서는 ‘이 기기의 익명 자료와 세션 지우기’를 제공한다. 이 동작은 이전 익명 접근권을 폐기하고 새 익명 세션을 만들며 계정 자료는 삭제하지 않는다. + +### 5.3 운영 모드 + +관리자는 공개 회원가입을 열거나 닫을 수 있다. SMTP 설정 없이 공개 가입을 켤 수 없다. 최초 관리자 생성은 안전한 초기화 절차로만 허용한다. 계정 역할은 user/admin, 상태는 인증 대기/활성/정지/탈퇴다. 관리자 화면은 사용자 본문과 파일을 기본 열람 기능으로 제공하지 않는다. 서버 운영자의 기술적 접근 가능성이 있으므로 E2EE를 제공한다고 안내하지 않는다. + +## 6. 이메일 원격 전송 + +### 6.1 대상 지정 + +송신자는 인증된 계정으로 로그인한 뒤 수신자의 이메일 전체를 입력한다. 이메일 일부 자동완성, 전체 회원 목록, 이메일 검색 API는 제공하지 않는다. 이메일은 대상 지정에만 쓰며 다른 사용자에게 계정의 접속 기기나 온라인 상태를 공개하지 않는다. + +대상은 인증된 기존 회원이어야 한다. 비회원 이메일에 첨부파일, 초대 메일, 가입 유도 메일을 자동 보내지 않는다. 유효하지 않은 대상과 수신 거부 대상의 오류는 ‘이 주소로 전송할 수 없습니다’처럼 처리하여 정지·차단 사유를 공개하지 않는다. 정확한 주소 전송의 성공·실패는 계정 존재를 일부 드러낼 수 있으므로 횟수 제한을 적용한다. + +초기에는 한 번에 이메일 한 명에게 보내며 다중 이메일 전송은 후속 범위다. 자신의 인증 이메일로 보내면 수신 요청 없이 ‘내 보관함’에 저장한다. 모든 로그인 기기는 계정 보관함과 읽음 상태를 공유한다. + +### 6.2 낯선 발신자와 허용 목록 + +사용자는 수신 정책을 다음 중 선택한다: 처음 보는 사람은 요청으로 받기(기본), 허용한 사람에게만 받기, 다른 사람에게 받지 않기. 차단은 허용보다 우선한다. + +처음 보는 발신자는 메타데이터 기반 수신 요청만 생성한다. 수신자는 인증된 발신 이메일·표시 이름, 유형·파일명·크기·개수를 보고 수락·거절·차단한다. ‘이 발신자는 다음부터 바로 받기’를 선택하면 허용 목록에 추가한다. 이름과 이메일 주소만 인증됐으며 실제 인물을 보증한다고 표시하지 않는다. + +낯선 발신자의 수락 전에는 파일을 업로드하지 않고 본문·이미지를 수신자에게 자동 노출하지 않는다. 요청은 기본 7일 후 만료한다. 승인 뒤 송신자가 오프라인이라면 ‘수락됨 · 전송 대기’로 표시하고 다음 접속에서 전송을 완료하도록 한다. 송신자 파일은 서버에 없으므로 자동 전달할 수 없으며 재선택이 필요할 수 있음을 명시한다. 입력 초안의 영구 로컬 저장은 기본 제공하지 않는다. + +허용된 발신자는 수신자가 오프라인이어도 즉시 업로드·저장할 수 있다. 따라서 ‘오프라인 원격 전송’은 허용 관계 또는 이미 수락된 요청에서 완성된다. 첫 전송을 받기 위해 한 번 수락하는 절차가 필요하다는 점을 보내기 화면에서 안내한다. + +승인 후 미완료 전송은 24시간 내 시작해야 한다. 승인·허용 상태는 업로드 시작과 전송 확정 시 다시 검사한다. 거절·차단·정지·만료되면 예약 용량과 임시 파일을 정리한다. 계정 보관 기간은 요청 시점이 아니라 데이터 저장 확정 시점부터 계산한다. + +## 7. 데이터 유형과 전송 처리 + +### 7.1 텍스트 + +다국어 UTF-8, 여러 줄, 기본 최대 1MiB를 지원한다. 일반 텍스트로 렌더링하고 복사 성공·실패를 안내한다. 서버 제한을 환경변수로 조정할 수 있다. + +### 7.2 URL + +HTTP/HTTPS만 허용한다. 여러 줄에서 URL을 분리하고 유효하지 않은 입력을 표시한다. 복사, 개별 새 탭 열기, 선택 URL 일괄 열기를 제공한다. 팝업 차단 시 개별 링크를 제시한다. 여러 URL은 전송 묶음에 속한 개별 항목으로 저장한다. + +미리보기는 전달 완료 이후 비동기적으로 수집하고 실패해도 전송은 유지한다. 서버 요청은 DNS IPv4/IPv6, 실제 연결 IP, 각 리디렉션을 검증하여 루프백·사설망·링크 로컬·메타데이터·내부 서비스 접근을 막는다. 타임아웃·응답 크기·리디렉션을 제한한다. 임의 쿠키·인증 정보를 보내지 않는다. 내부 URL 자체 전달은 허용하고 미리보기는 생략한다. 파비콘은 제한된 이미지로 서버에서 수집하여 수신자 브라우저가 자동으로 외부 사이트를 호출하지 않게 한다. + +### 7.3 파일 + +파일 선택, 여러 파일, 드래그 앤 드롭, 파일명·크기, 개별 업로드 진행률, 취소, 다운로드를 지원한다. 파일당 기본 2GiB이며 multipart 스트리밍으로 저장한다. 전체 파일을 메모리에 적재하지 않는다. 서버는 실제 바이트 수와 선언 크기를 검증한다. + +묶음 내 파일마다 성공·실패를 표시하고 성공한 파일은 유지하며 실패 파일만 재시도할 수 있다. 수락은 서버에 저장된 변경 불가능한 파일 목록과 크기에 결합한다. 파일 변경·추가·크기 증가 시 새 요청이 필요하다. 요청에 없는 파일 업로드는 거절한다. + +지원 래스터 이미지에 인증된 미리보기를 제공한다. HTML·SVG·실행 콘텐츠는 인라인 실행을 금지한다. 다운로드는 실제 접근권을 검사하고 안전한 Content-Disposition·nosniff를 적용한다. 원본 이름은 경로로 사용하지 않고 서버 생성 키로 저장하며 저장 경로를 공개하지 않는다. + +업로드 중단·브라우저 종료·저장 공간 부족 시 임시 파일과 용량 예약을 회수한다. 미확정 업로드는 기본 24시간 내 정리한다. 초기에는 청크 재개와 백그라운드 업로드를 보장하지 않는다. 파일을 안전하다고 보증하지 않는다. + +### 7.4 클립보드와 중복 방지 + +사용자 동작으로 붙여넣기·복사를 제공하고 권한이 있으면 클립보드 읽기 버튼을 제공한다. 자동 백그라운드 감시는 없다. 전송 요청·수락·업로드 확정은 idempotency key로 중복 처리를 막는다. 같은 키에 다른 payload가 오면 충돌로 거절한다. + +## 8. 보관함과 상태 + +| 화면 | 대상과 기능 | +|---|---| +| 이번 기기 | 익명 즉석 수신·전송 결과, 복사·열기·다운로드·삭제 | +| 받은 항목 | 계정의 전달 완료 자료, 읽음·검색·필터·즐겨찾기·삭제 | +| 수신 요청 | 아직 수락하지 않은 원격 요청, 수락·거절·차단 | +| 내 보관함 | 자신에게 저장한 자료, 다른 기기에서 사용·재전송 | +| 보낸 항목 | 원격 전송의 요청·전송·확인 상태, 내 목록 삭제 | +| 즐겨찾기 | 계정이 보존하도록 지정한 자료 | + +영구 기기별 계정 수신함은 없다. ‘이번 기기’는 익명 세션 접근권이며 계정 받은 항목과 별개다. 일반 방문자가 다른 익명 기기의 과거 수신 내역을 볼 수 없다. 계정 목록은 검색, 유형·미확인 필터, 최신순, 페이지네이션과 다중 선택을 지원한다. + +요청 상태는 대기/수락/거절/만료/취소, 전송 상태는 업로드 대기/업로드 중/저장 완료/실패, 읽음 상태는 미확인/확인 완료로 분리한다. 읽음은 상세 열기·명시적 복사·링크 열기·다운로드 시작에서 처리한다. 다운로드 시작은 다운로드 완료나 파일 실행을 의미하지 않는다. 목록 노출·썸네일·실시간 이벤트만으로 읽음 처리하지 않는다. 다시 미확인으로 바꿀 수 있다. + +계정 읽음은 모든 로그인 기기에 공유되고 익명 읽음은 해당 익명 세션에만 적용한다. 확인한 자료는 자동 삭제하지 않는다. 송신자에게 확인 여부는 표시하되 수신자의 삭제·즐겨찾기 상태는 노출하지 않는다. + +## 9. 밀어서 삭제와 실행 취소 + +삭제의 기본 의미는 ‘내 목록에서 삭제’다. 송신자가 보낸 항목을 삭제해도 수신 자료를 회수하지 않는다. 수신자가 삭제해도 송신자의 접근권은 남는다. 아직 수락되지 않은 자신이 보낸 요청은 별도 ‘요청 취소’ 동작을 제공한다. + +- 모바일 행을 왼쪽으로 밀면 삭제 버튼을 드러내고 버튼을 눌러 삭제한다. 밀기만으로 즉시 삭제하지 않는다. +- 한 행만 펼치고 다른 행·바깥 터치로 닫는다. 세로 스크롤과 가로 의도를 구분하고 시스템 뒤로 가기 제스처와 충돌하지 않게 한다. +- 링크·복사·다운로드 버튼이나 텍스트 선택 조작에서 제스처를 시작하지 않는다. +- 행을 부드럽게 접고 ‘삭제됨 · 실행 취소’를 5초 제공한다. 다중 삭제도 작업별 서버 복원 ID를 사용한다. +- 서버의 5초 유예 안에서만 복원하고 자연 만료된 자료는 복원하지 않는다. 유예 중에는 물리 파일 정리를 보류한다. +- 서버 실패 시 행·선택·카운트를 복원하고 이해하기 쉬운 오류를 표시한다. 다른 로그인 기기의 상태도 동기화한다. +- 데스크톱 행 메뉴, 모바일 명시적 삭제 버튼, 키보드·스크린리더 접근을 함께 제공한다. 터치 목표 최소 44px와 동작 줄이기를 지원한다. +- 미확인/확인 필터와 선택 삭제를 제공해 확인한 항목을 쉽게 정리한다. + +수신 요청을 목록에서 정리할 때는 거절로 처리하여 송신자와 상태가 일치하게 한다. 이미 진행 중인 전송을 단순 목록 삭제로 숨겨서 계속 업로드하게 하지 않는다. 진행 중 전송은 먼저 취소하고 양쪽에 상태를 알린다. 즐겨찾기 직접 삭제는 해당 보존 권한도 제거한다. + +## 10. 보관 기간과 정리 + +| 유형 | 기본 기간 | +|---|---| +| 텍스트 | 30일 | +| URL | 30일 | +| 파일 | 7일 | +| 계정 즐겨찾기 | 자동 만료 없음 | +| 원격 수신 요청 | 7일 | +| 즉석 수신 요청 | 60초 | + +일반 보관 기간은 데이터 저장 확정부터 계산하며 읽음으로 연장하지 않는다. 익명 자료에도 동일한 데이터 보관 기간을 적용하지만 익명 세션 만료·초기화 후 접근은 복구할 수 없다. 익명 즐겨찾기는 제공하지 않으며 로그인 후 내 계정에 저장해서 사용할 수 있다. + +즐겨찾기는 사용자별 보존 권한이다. 한 사용자가 즐겨찾기해도 상대방의 기한은 늘어나지 않는다. 해제 시 원래 만료가 지났으면 즉시 만료 안내를 한다. 데이터는 유효한 접근권·즐겨찾기 또는 실행 취소 유예가 남아 있으면 유지하고 마지막 권한 종료 후 본문·파일을 정리한다. 여러 전송이 같은 파일을 참조하면 모든 참조를 확인한다. + +운영자가 기본 기간을 바꾸면 새 자료에 적용한다. 파일·메타데이터·용량 회계를 함께 정리하며 고아 파일과 DB 불일치를 정기 확인한다. 최소 감사 기록은 본문과 원본 없이 기본 30일 보관한다. + +## 11. 남용 방지와 용량 + +다음은 초기 정책 기본값이며 운영 설정으로 조정할 수 있다. + +| 제한 | 초기 기본값 | +|---|---| +| 파일당 크기 | 2GiB | +| 전송당 파일 또는 URL | 20개 | +| 인증 계정 저장 용량 | 10GiB | +| 익명 세션 저장 용량 | 2GiB | +| 익명 세션당 동시 전송 | 1개 | +| 계정당 동시 업로드 | 2개 | +| 디스크 최소 여유 공간 | 5GiB | +| 익명 요청 발신 | 세션당 분당 5회, IP당 분당 20회 | +| 같은 익명 수신자에게 요청 | 송수신 쌍별 분당 2회 | +| 원격 신규 수신 요청 | 발신 계정당 하루 20회 | +| 미처리 원격 요청 | 수신 계정당 최대 50개 | + +계정·익명 세션·IP·송수신 쌍을 함께 제한하고 익명 쿠키를 새로 만들어도 IP 제한을 우회하지 못하게 한다. 공유 IP의 정상 사용자 영향이 있으므로 운영에서 조정 가능하게 한다. 거절 후 같은 쌍은 기본 5분간 재요청을 제한한다. 수신자는 익명 상대를 세션 단위로 차단하고 발견 허용을 끌 수 있다. 익명 차단은 상대의 새 세션 생성까지 영구 식별하지 못한다는 한계를 명시한다. + +익명 저장소에는 서비스 전체 20GiB 기본 상한을 추가하여 다수 세션 생성으로 NAS를 채우는 것을 제한한다. 수신 수락은 업로드 시작의 필수 조건이고 파일 크기 기반 용량 예약을 원자적으로 처리한다. 저장 공간 부족 시 수락·신규 업로드를 거절하고 기존 다운로드는 가능한 한 유지한다. + +원본 파일은 송신 계정 또는 익명 세션의 사용량으로 계산하고 수신자가 즐겨찾기하면 수신자의 즐겨찾기 용량에도 논리 크기로 계산한다. 자기 보관함은 중복 합산하지 않는다. 실제 파일은 중복 저장하지 않아도 논리 용량은 계정별로 제한한다. 파일을 상대가 보존하는 동안 원본 소유자의 용량도 유지될 수 있음을 안내한다. 만료·삭제·재전송 참조와 용량 예약을 일관되게 계산한다. + +이메일 인증만으로 남용을 막을 수 있다고 가정하지 않는다. 가입·로그인·메일 재발급·코드 대입에도 IP와 계정 기반 속도 제한을 둔다. 원격 발신자는 차단·신고할 수 있고 관리자에게 계정 정지와 신고 처리 화면을 제공한다. 신고는 식별자·사유 중심이며 본문·파일을 자동 복제하지 않는다. 초기에는 원격 요청마다 이메일 알림을 발송하지 않아 메일 폭탄을 방지한다. 인증·복구용 메일만 필수다. + +## 12. 실시간 통신과 PWA + +REST API와 DB가 상태의 기준이며 Socket.IO는 기기 발견·전송 요청·변경 알림을 전달한다. 서버가 인증한 익명 세션, 계정, 발견 범위 또는 공간으로 room을 구성하고 클라이언트가 임의 room에 가입할 수 없게 한다. 이벤트에는 필요한 최소 정보만 포함한다. + +세션 만료·계정 정지·범위 변경·공간 퇴장 시 room과 권한을 다시 계산한다. 다중 탭 중 하나가 닫혔다고 기기 전체를 오프라인 처리하지 않는다. 재연결·탭 복귀 시 현재 요청과 수신 목록을 API로 다시 조회한다. 모바일 백그라운드나 브라우저 종료 시 온라인 상태를 보장하지 않는다. + +PWA는 hop. 이름·아이콘·독립 실행·정적 리소스 캐시를 제공한다. 지원 플랫폼의 공유 메뉴에서는 파일·URL·텍스트를 보내기 초안으로 가져오고 사용자가 대상과 전송을 확정한다. 자동 전송은 하지 않는다. 미지원 브라우저는 붙여넣기·파일 선택으로 대체한다. + +인증 API, 메시지, 파일, 수신자 정보는 공용 서비스 워커 캐시에 넣지 않는다. 로그아웃과 계정 전환 시 계정별 화면·초안·캐시를 정리한다. 익명 자료는 별도 수명 정책을 적용하고 공유 기기 초기화 동작을 제공한다. Web Push와 오프라인 새 전송은 초기 범위가 아니다. + +## 13. UI 구성 + +미니멀하고 가벼운 전송 도구로 디자인한다. 라이트·다크·시스템 테마, 모바일 우선 반응형, 명확한 진행 상태와 오류 안내를 제공한다. + +- 홈: hop., 내 기기 이름 변경, 발견 허용, 같은 네트워크 기기 카드, QR·코드 연결, 빠른 전송. 로그인 없이 접근한다. +- 즉석 보내기: 대상 기기, 파일·텍스트·URL, 수락 대기·전송 진행·결과. +- 즉석 받기: 조용한 요청 카드, 수락·거절·차단, 전송 후 복사·열기·다운로드. +- 이번 기기: 익명 전송·수신 목록과 삭제, 로그인 후 선택 자료 저장. +- 이메일로 보내기: 로그인 안내 또는 이메일 입력, 요청인지 즉시 전송인지 상태 안내. +- 계정 보관함: 받은 항목·수신 요청·내 보관함·보낸 항목·즐겨찾기. +- 계정 화면: 가입·인증·로그인·복구, 이메일·비밀번호, 허용·차단, 용량, 세션, 탈퇴. +- 설정: 현재 기기 이름, 발견 허용, 테마, 익명 기기 초기화. +- 관리자: 가입 모드·계정 정지·신고·용량 제한·정리 작업과 저장소 상태. + +데스크톱은 기기 카드와 전송 패널을 중심으로 구성한다. 모바일 하단 내비게이션은 ‘기기’, ‘이번 기기’, ‘보관함’으로 하고 로그인은 보관함과 이메일 전송에서만 요구한다. 주변 전송보다 계정 가입·보관함을 더 크게 강조하지 않는다. 코드 연결은 네트워크가 달라도 쓸 수 있도록 눈에 띄게 제공한다. + +## 13.1 가벼운 진입 흐름 + +첫 접속에서 회원가입 모달, 이름 입력 강제, 튜토리얼, 알림 권한 요청을 띄우지 않는다. 기본 기기 이름으로 곧바로 사용하고 이름 변경은 홈에서 한 번의 조작으로 접근한다. 위치·Bluetooth·연락처 권한을 요구하지 않는다. 로그인 버튼은 보조로 배치한다. + +발견 가능한 기기가 없으면 ‘상대도 hop.을 열어주세요’와 QR·코드 연결을 보여준다. 회원가입을 해결책으로 먼저 제시하지 않는다. 파일·텍스트·URL은 하나의 전송 입력 영역에서 쉽게 선택하고 드래그 또는 붙여넣기로 시작할 수 있게 한다. 보안 설정의 복잡한 용어를 일반 사용자 흐름에 노출하지 않는다. 수락·거절과 상태 안내는 짧고 분명한 문구를 사용한다. + +## 13.2 광고 수익화 + +메인 화면에 광고를 배치할 수 있도록 독립적인 광고 슬롯을 설계한다. 초기 구현은 광고 기능을 꺼둔 상태로 제공하며 실제 광고 계정·코드가 준비되면 운영자가 활성화한다. 특정 사업자의 수익이나 승인을 보장하지 않는다. 광고 사업자 연동 및 공개 시점의 정책·동의 요건 검토는 실제 활성화 단계에서 수행한다. + +- 데스크톱은 기기·전송 영역 바깥의 보조 영역, 모바일은 핵심 동작 아래 별도 영역에 한 개의 기본 슬롯을 둔다. +- ‘광고’라고 명확히 표시하고 전송·다운로드·수락 버튼처럼 보이는 자체 UI를 만들지 않는다. +- 전면 광고, 강제 시청, 다운로드 전 광고 클릭, 팝업 광고, 수락 대화상자 안 광고는 제공하지 않는다. +- 전송 진행·수신 요청·파일 상세·로그인·계정 보관함 화면에는 광고를 표시하지 않는다. 광고 로딩 실패나 차단이 주요 기능을 막아서는 안 된다. +- 슬롯 공간을 예약하여 늦은 로딩이 전송 버튼 위치를 바꾸거나 오터치를 유발하지 않게 한다. 광고 스크립트는 핵심 앱 로딩을 차단하지 않는다. +- 파일명·본문·공유 URL·이메일·기기 이름·공간 코드·접속 IP 그룹을 광고 타기팅 데이터나 광고 요청 매개변수로 보내지 않는다. +- 광고 스크립트가 같은 페이지에서 실행되면 그 자체로 개인정보 경계가 된다고 가정하지 않는다. 사업자가 지원하는 격리 방식과 최소한의 비민감 홈 상태를 검토하고, 계정·요청·내용 화면으로 진입할 때 광고를 제거하거나 별도 비광고 문서로 전환한다. +- 제3자 광고가 활성화되면 ‘외부 서비스 의존 없음’ 또는 ‘접속 정보가 외부로 전혀 전달되지 않음’이라고 안내하지 않는다. 해당 사업자 수집과 필요한 동의·설정을 실제 구성에 맞춰 공개한다. + +광고 없이도 핵심 기능을 완전히 사용할 수 있어야 한다. 유료 구독이나 파일 보관 업셀은 초기 범위에 추가하지 않는다. + +## 14. 기술 아키텍처 + +| 영역 | 기술 | +|---|---| +| Frontend | Vue 3, TypeScript, Vite, Tailwind CSS | +| Backend | Node.js, TypeScript, Fastify | +| 실시간 | Socket.IO | +| DB | SQLite, Drizzle ORM, 마이그레이션 | +| 파일 | NAS 비공개 영속 볼륨 | +| 인증 | 익명 세션과 계정 서버 세션, Argon2id | +| 메일 | 설정 가능한 SMTP | +| 배포 | Docker Compose, Nginx Proxy Manager, HTTPS | + +apps/web, apps/server, packages/shared로 단일 저장소를 구성한다. 단일 NAS에서 시작하고 DB·저장소·메일 모듈을 분리한다. Redis·PostgreSQL·외부 저장소·다중 서버는 초기 필수가 아니며 공개 이용량 확대 시 별도 검증한다. + +## 15. 데이터 모델 + +내부 UUID와 공개 이름·이메일을 분리한다. 시간은 UTC 저장, UI는 현지 시간으로 표시한다. 외래 키, 유일 제약, 만료·목록 조회 인덱스를 적용한다. + +| 테이블 | 주요 역할 | +|---|---| +| users | 이메일, 표시 이름, 비밀번호 해시, 역할·상태·인증 시간 | +| account_sessions | 계정 세션 해시, 만료·폐기·최종 접속 | +| guest_sessions | 익명 세션 해시, 만료·폐기 | +| browser_devices | 익명 세션에 속한 기기 이름, 발견 허용, 최종 접속 | +| connection_spaces | 생성 세션, QR 토큰 해시, 코드 해시, 만료 | +| space_members | 공간·기기, 승인·퇴장 상태 | +| account_tokens | 인증·복구·이메일 변경용 일회용 토큰 해시 | +| transfer_requests | local/email 모드, 발신 주체, 수신 주체, 메타데이터, 수락·만료·취소 | +| transfer_batches | 요청 참조, 저장 상태, idempotency key, request hash | +| items | 묶음, 유형, 본문·URL·파일 참조, 저장·만료 시각 | +| item_access | 항목, 계정 또는 익명 주체, sender/recipient/self 역할, 읽음·즐겨찾기·삭제 | +| files | 소유 주체, 저장 키, 파일명·타입·크기·체크섬·상태 | +| user_rules | 허용·차단 관계와 수신 정책 | +| quota_reservations | 업로드·주체·예약 바이트·만료 | +| deletion_operations | 삭제 주체·복원 기한·대상·복원 결과 | +| reports | 신고자·발신자·요청 또는 항목·사유·처리 | +| system_settings | 운영 정책과 제한 | +| audit_events | 최소 행위·식별자·결과·시간 | + +주체 필드는 user_id와 guest_session_id 중 정확히 하나만 값이 있도록 CHECK 제약을 적용한다. 접근권은 원본 항목과 분리하고 역할별 중복을 막는다. 온라인 상태는 활성 Socket 연결에서 계산하며 DB의 과거 last_seen만으로 온라인을 판단하지 않는다. + +발견 IP 그룹은 접속 중 임시 서버 상태로 유지하고 공개 room 이름에 원본 IP를 넣지 않는다. 요청 수락·용량 예약·전송 확정·접근권 생성은 트랜잭션과 상태 전이 검증을 사용한다. 재전송은 새로운 전송·만료를 만들며 접근 가능한 기존 파일을 참조할 수 있다. 물리 정리는 모든 참조와 유예를 검사한다. + +탈퇴 시 로그인 개인정보·토큰·세션·본인 접근권을 정리한다. 다른 수신자의 유효한 전달 자료는 자체 만료까지 유지하고 발신자를 ‘탈퇴한 사용자’로 표시한다. 이 보존 정책은 탈퇴 전에 안내한다. 익명 세션 정리도 상대의 유효한 접근권을 삭제하지 않는다. + +## 16. API와 이벤트 + +```text +POST /api/guest/session +GET /api/device/me +PATCH /api/device/me +POST /api/device/reset +GET /api/discovery/devices +POST /api/spaces +POST /api/spaces/join +POST /api/spaces/:id/members/:memberId/approve +DELETE /api/spaces/:id/members/:memberId +DELETE /api/spaces/:id + +POST /api/auth/setup +POST /api/auth/register +POST /api/auth/verify-email +POST /api/auth/resend-verification +POST /api/auth/login +POST /api/auth/logout +GET /api/auth/me +POST /api/auth/forgot-password +POST /api/auth/reset-password +POST /api/auth/change-password +POST /api/auth/change-email +GET /api/account/sessions +DELETE /api/account/sessions/:id +DELETE /api/account + +POST /api/transfer-requests +GET /api/transfer-requests +POST /api/transfer-requests/:id/accept +POST /api/transfer-requests/:id/reject +POST /api/transfer-requests/:id/cancel +POST /api/uploads +DELETE /api/uploads/:id +POST /api/transfers/:id/complete + +GET /api/device/items +GET /api/inbox +GET /api/vault +GET /api/sent +GET /api/favorites +GET /api/access/:id +PATCH /api/access/:id/read +PATCH /api/access/:id/favorite +POST /api/access/delete-batch +POST /api/deletion-operations/:id/undo +POST /api/access/:id/save-to-account +POST /api/access/:id/resend +GET /api/access/:id/download +GET /api/access/:id/preview + +GET /api/account/preferences +PATCH /api/account/preferences +GET /api/account/rules +PUT /api/account/rules/:targetUserId +DELETE /api/account/rules/:targetUserId +GET /api/account/storage +POST /api/reports +GET /api/admin/users +PATCH /api/admin/users/:id +GET /api/admin/settings +PATCH /api/admin/settings +GET /api/admin/reports +PATCH /api/admin/reports/:id +GET /api/health +``` + +API는 익명 허용·계정 필요·관리자 필요를 명시적으로 분리한다. 익명 허용은 인증 불필요를 뜻하지 않으며 서버 익명 세션과 대상 권한을 검증한다. API 목록은 커서 페이지네이션, 오류는 code/message/requestId를 사용한다. read/favorite은 목표 값을 받아 재시도 가능한 상태 변경으로 처리한다. + +Socket.IO 이벤트: device:online, device:offline, device:updated, space:updated, request:created, request:updated, transfer:updated, inbox:updated, access:updated, account:revoked. 파일 바이트는 Socket.IO 이벤트로 보내지 않는다. 클라이언트 업로드 진행률과 서버 저장 확정은 구분한다. + +## 17. 보안 요구사항 + +HTTPS, 안전한 쿠키, CSRF 토큰·Origin 검증, Socket.IO 인증·Origin 검증을 적용한다. 모든 항목·파일·요청·공간·이벤트에서 계정 또는 익명 세션 접근권을 확인한다. 리소스 ID·기기 ID·공간 ID는 인증 비밀값이 아니다. + +프록시의 실제 클라이언트 IP 해석은 명시적으로 신뢰한 Nginx Proxy Manager와 필요한 상위 프록시만 허용한다. 임의 X-Forwarded-For로 발견 범위와 rate limit을 우회하지 못하게 한다. Cloudflare를 경유한다면 신뢰 가능한 원본 IP 전달을 실제 환경에서 검증한다. 검증된 IP를 얻을 수 없으면 같은 네트워크 탐색을 끄고 QR·코드로 대체한다. 모든 사용자를 프록시 IP 하나로 묶어 노출하는 구현은 금지한다. + +입력 검증, XSS 방어, CSP, 경로 조작 방어, 비공개 파일 저장, SSRF 방어, 업로드 제한을 구현한다. 로그에 세션·토큰·메시지·민감 URL을 남기지 않는다. QR 초대 토큰이 로그·리퍼러로 새지 않도록 처리하고 가입·로그인 페이지가 토큰을 외부로 전송하지 않게 한다. + +서비스 공개 전에 가입 모드, SMTP, 요청 제한, 익명 저장소 상한, 백업·복구, 개인정보 안내와 신고·정지 기능을 설정한다. 사용자에게 E2EE나 물리적 근접 탐지를 제공한다고 표현하지 않는다. + +## 18. Docker와 운영 + +```text +hop/ + apps/web/ + apps/server/ + packages/shared/ + docker/ + compose.yaml + Dockerfile + .env.example + SPEC.md + README.md +``` + +```env +NODE_ENV=production +TZ=Asia/Seoul +PORT=3000 +APP_URL=https://hop.noname.company +DATABASE_PATH=/data/database/hop.sqlite +UPLOAD_DIR=/data/uploads +SESSION_SECRET=replace-with-a-long-random-secret +DISCOVERY_SECRET=replace-with-another-random-secret +REGISTRATION_MODE=closed +EMAIL_VERIFICATION_REQUIRED=true +SAME_NETWORK_DISCOVERY_ENABLED=true +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= +MAX_FILE_SIZE_MB=2048 +MAX_TEXT_SIZE_KB=1024 +USER_STORAGE_LIMIT_MB=10240 +GUEST_STORAGE_LIMIT_MB=2048 +TOTAL_GUEST_STORAGE_LIMIT_MB=20480 +MIN_FREE_DISK_MB=5120 +MAX_ITEMS_PER_TRANSFER=20 +DEFAULT_FILE_RETENTION_DAYS=7 +DEFAULT_TEXT_RETENTION_DAYS=30 +DEFAULT_URL_RETENTION_DAYS=30 +LOCAL_REQUEST_TIMEOUT_SECONDS=60 +EMAIL_REQUEST_RETENTION_DAYS=7 +ACCEPTED_UPLOAD_START_HOURS=24 +SPACE_RETENTION_MINUTES=10 +SPACE_MAX_DEVICES=10 +GUEST_SESSION_RETENTION_DAYS=30 +ORPHAN_UPLOAD_RETENTION_HOURS=24 +DELETE_UNDO_SECONDS=5 +AUDIT_RETENTION_DAYS=30 +``` + +MB/KB 변수는 MiB/KiB 기준으로 계산하고 README에 표기한다. rate limit, 동시 업로드, 프록시 신뢰 범위, SMTP TLS와 정리 주기를 추가 설정 가능하게 한다. 비밀값과 실제 데이터는 Git에 포함하지 않는다. + +Compose로 실행·마이그레이션하고 DB·파일은 영속 볼륨으로 분리한다. 자동 재시작·health check·로그를 제공한다. Nginx Proxy Manager의 HTTPS·WebSocket·업로드 크기와 시간 제한을 문서화한다. 앱의 2GiB 한도는 Cloudflare와 중간 프록시 한도를 보장하지 않으므로 실제 배포 경로에서 검증하고 필요한 직접 업로드 경로 또는 상한 조정을 문서화한다. + +README에는 설치, 관리자 초기화, 가입 열기·닫기, SMTP, 발견 범위와 프록시 IP 설정, QR 연결, 업데이트, 백업·복구, 공간 부족·정리 실패 대응, 익명 자료 접근 제한을 포함한다. DB·파일의 일관된 백업과 복원 검증을 제공한다. + +## 19. 구현 단계 + +### Phase 1 가입 없는 즉석 전송 + +프로젝트·Docker, 익명 세션, 이름 변경, 발견 허용, 검증된 IP 그룹, QR·코드 공간과 선택적 참여 승인, 온라인 상태, 요청 수락·거절, 텍스트·URL 전송, 이번 기기 목록을 구현한다. 범위 권한 검사와 요청 제한은 이 단계부터 적용한다. + +### Phase 2 파일과 삭제 + +수락 이후 스트리밍 업로드, 진행률·취소, 인증 다운로드·미리보기, 익명 용량·전체 상한, 만료·고아 정리, 밀어서 삭제·실행 취소, 실제 모바일 동작을 구현한다. + +### Phase 3 이메일 원격 전송 + +이메일 가입·인증·로그인·복구, 수신 요청·허용·차단, 오프라인 저장, 계정 보관함·내 보관함, 읽음·즐겨찾기·검색, 계정 저장 용량과 익명 자료 선택 저장을 구현한다. + +### Phase 4 편의와 운영 + +PWA·공유 메뉴 초안, URL 미리보기, 테마, 비활성 기본 광고 슬롯, 재전송, 계정 세션 관리·탈퇴, 신고·관리자·가입 모드와 백업 문서를 완성한다. + +### Phase 5 검증 + +익명·계정 간 권한 격리, 발견 범위·프록시 IP, 코드 대입 제한, 동시 용량 예약, 요청 상태 전이, 파일 중단, 삭제·복원·만료·즐겨찾기 정리, 재연결, 모바일·Docker 데이터 보존과 백업 복원을 검증한다. + +## 20. 완료 기준 + +1. 로그인 없이 사이트를 열어 기기 이름을 변경하고 재접속 후 유지할 수 있다. +2. 같은 네트워크 목록에 전체 사이트 이용자가 노출되지 않고 발견 끄기가 즉시 반영된다. +3. 다른 네트워크의 기기를 QR·코드로 연결하며 선택적 참여 승인·퇴장·만료가 적용된다. +4. 여러 탭은 하나의 기기로 보이고 마지막 연결 종료 시 오프라인 처리된다. +5. 수락 전에 파일 업로드를 시작할 수 없고 거절·타임아웃 시 전달하지 않는다. +6. 익명 기기 간 파일·텍스트·URL을 실제로 전달하고 복사·다운로드할 수 있다. +7. 이메일 인증 계정으로 원격 요청을 보내고 첫 수락 이후 전달을 완료할 수 있다. +8. 허용된 발신자는 수신자가 오프라인이어도 저장할 수 있다. +9. 상대가 수락해도 송신자가 오프라인이면 전송 대기 상태를 정확히 안내한다. +10. 같은 계정의 여러 기기에서 동일한 계정 목록과 읽음·삭제 상태를 공유한다. +11. 밀어서 삭제가 스크롤과 충돌하지 않고 5초 실행 취소·서버 실패 복구가 작동한다. +12. 한쪽 목록 삭제가 상대의 자료를 회수하지 않는다. +13. 보관 기간·개별 즐겨찾기·마지막 접근권 정리가 일관되게 적용된다. +14. 다른 세션의 기기·파일·공간·계정 항목 ID를 알아도 무단 접근할 수 없다. +15. 동시·중복 요청이 업로드 제한·용량 제한·수신 정책을 우회하지 못한다. +16. 로그아웃·계정 전환과 익명 초기화가 정해진 데이터 경계를 유지한다. +17. Docker 재배포·NAS 재시작 후 자료가 유지되고 백업에서 복구할 수 있다. +18. 첫 접속에서 가입·이름 입력·권한 허용 없이 즉석 전송을 시작할 수 있다. +19. 광고 비활성·로딩 실패·차단 상태에서도 전송과 수신이 정상 동작하고 광고 때문에 핵심 버튼 위치가 바뀌지 않는다. + +## 21. 초기 제외 범위와 개발 지침 + +Bluetooth 기반 물리적 근접 탐색, WebRTC P2P, 기기별 영구 계정 수신함, 비회원 이메일 전송·초대, 공개 다운로드 링크, 네이티브 앱, 자동 클립보드 동기화, E2EE, Web Push, 파일 버전 관리, 전송 회수, 다중 이메일 동시 전송, 요금제와 다중 서버는 초기 범위에서 제외한다. + +이 문서를 기준으로 실제 API·DB·파일 저장을 연결한 앱을 개발한다. 먼저 아키텍처, 익명·계정 권한 모델, 요청·업로드 상태 전이, 정리·용량 규칙과 단계별 계획을 제시한다. Phase 1부터 구현하며 빌드와 관련 테스트를 단계별 실행한다. 더미 데이터로 기능 완료를 주장하지 않는다. + +홈의 중심은 가입 없는 즉석 전송이며 계정 기능 때문에 진입 흐름을 바꾸지 않는다. 기기 이름 변경은 필수다. 브라우저의 기술적 제약이나 배포 프록시 제한을 숨기지 않는다. 실기기에서 확인하지 못한 모바일·PWA 동작은 검증 범위를 구분해 보고한다. 명세 밖의 중요한 제품 범위 변경은 구현 전에 질문한다. 코드·설치 문서·환경 예시·검증 결과·알려진 제한을 함께 제공한다. diff --git a/apps/server/drizzle.config.ts b/apps/server/drizzle.config.ts new file mode 100644 index 0000000..8e791c3 --- /dev/null +++ b/apps/server/drizzle.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'drizzle-kit'; +export default defineConfig({ schema: './src/db/schema.ts', out: './drizzle', dialect: 'sqlite' }); diff --git a/apps/server/drizzle/0000_aberrant_scourge.sql b/apps/server/drizzle/0000_aberrant_scourge.sql new file mode 100644 index 0000000..5ff83f0 --- /dev/null +++ b/apps/server/drizzle/0000_aberrant_scourge.sql @@ -0,0 +1,126 @@ +CREATE TABLE `item_access` ( + `id` text PRIMARY KEY NOT NULL, + `itemId` text NOT NULL, + `guestId` text NOT NULL, + `role` text NOT NULL, + `read` integer DEFAULT false NOT NULL, + `deletedAt` integer, + FOREIGN KEY (`itemId`) REFERENCES `items`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `access_subject_role` ON `item_access` (`itemId`,`guestId`,`role`);--> statement-breakpoint +CREATE INDEX `access_guest` ON `item_access` (`guestId`,`deletedAt`);--> statement-breakpoint +CREATE TABLE `transfer_batches` ( + `id` text PRIMARY KEY NOT NULL, + `requestId` text NOT NULL, + `status` text NOT NULL, + `storedAt` integer, + FOREIGN KEY (`requestId`) REFERENCES `transfer_requests`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `transfer_batches_requestId_unique` ON `transfer_batches` (`requestId`);--> statement-breakpoint +CREATE TABLE `guest_blocks` ( + `id` text PRIMARY KEY NOT NULL, + `guestId` text NOT NULL, + `targetGuestId` text NOT NULL, + FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`targetGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `guest_block_pair` ON `guest_blocks` (`guestId`,`targetGuestId`);--> statement-breakpoint +CREATE TABLE `browser_devices` ( + `id` text PRIMARY KEY NOT NULL, + `guestId` text NOT NULL, + `name` text NOT NULL, + `code` text NOT NULL, + `discoverable` integer DEFAULT true NOT NULL, + `lastSeen` integer NOT NULL, + FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "device_name_length" CHECK(length("browser_devices"."name") between 1 and 40) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `browser_devices_guestId_unique` ON `browser_devices` (`guestId`);--> statement-breakpoint +CREATE UNIQUE INDEX `browser_devices_code_unique` ON `browser_devices` (`code`);--> statement-breakpoint +CREATE TABLE `guest_sessions` ( + `id` text PRIMARY KEY NOT NULL, + `tokenHash` text NOT NULL, + `expiresAt` integer NOT NULL, + `revokedAt` integer, + `createdAt` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `guest_sessions_tokenHash_unique` ON `guest_sessions` (`tokenHash`);--> statement-breakpoint +CREATE INDEX `guest_expiry` ON `guest_sessions` (`expiresAt`);--> statement-breakpoint +CREATE TABLE `idempotency_records` ( + `id` text PRIMARY KEY NOT NULL, + `guestId` text NOT NULL, + `operation` text NOT NULL, + `key` text NOT NULL, + `payloadHash` text NOT NULL, + `response` text NOT NULL, + `createdAt` integer NOT NULL, + FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idempotency_scope` ON `idempotency_records` (`guestId`,`operation`,`key`);--> statement-breakpoint +CREATE TABLE `items` ( + `id` text PRIMARY KEY NOT NULL, + `batchId` text NOT NULL, + `kind` text NOT NULL, + `text` text, + `url` text, + `createdAt` integer NOT NULL, + `expiresAt` integer NOT NULL, + FOREIGN KEY (`batchId`) REFERENCES `transfer_batches`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "item_payload" CHECK(("items"."kind" = 'text' AND "items"."text" IS NOT NULL AND "items"."url" IS NULL) OR ("items"."kind" = 'url' AND "items"."url" IS NOT NULL AND "items"."text" IS NULL)) +); +--> statement-breakpoint +CREATE INDEX `items_expiry` ON `items` (`expiresAt`);--> statement-breakpoint +CREATE TABLE `space_members` ( + `id` text PRIMARY KEY NOT NULL, + `spaceId` text NOT NULL, + `deviceId` text NOT NULL, + `status` text NOT NULL, + `joinedAt` integer NOT NULL, + FOREIGN KEY (`spaceId`) REFERENCES `connection_spaces`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`deviceId`) REFERENCES `browser_devices`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `space_device` ON `space_members` (`spaceId`,`deviceId`);--> statement-breakpoint +CREATE TABLE `transfer_requests` ( + `id` text PRIMARY KEY NOT NULL, + `mode` text DEFAULT 'local' NOT NULL, + `senderGuestId` text NOT NULL, + `recipientGuestId` text NOT NULL, + `kind` text NOT NULL, + `byteSize` integer NOT NULL, + `count` integer NOT NULL, + `status` text NOT NULL, + `createdAt` integer NOT NULL, + `expiresAt` integer NOT NULL, + `acceptedAt` integer, + `startBy` integer, + FOREIGN KEY (`senderGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`recipientGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "request_different_guests" CHECK("transfer_requests"."senderGuestId" <> "transfer_requests"."recipientGuestId") +); +--> statement-breakpoint +CREATE INDEX `requests_sender_time` ON `transfer_requests` (`senderGuestId`,`createdAt`);--> statement-breakpoint +CREATE INDEX `requests_recipient_time` ON `transfer_requests` (`recipientGuestId`,`createdAt`);--> statement-breakpoint +CREATE INDEX `requests_expiry` ON `transfer_requests` (`status`,`expiresAt`);--> statement-breakpoint +CREATE TABLE `connection_spaces` ( + `id` text PRIMARY KEY NOT NULL, + `creatorGuestId` text NOT NULL, + `tokenHash` text NOT NULL, + `codeHash` text NOT NULL, + `approvalRequired` integer NOT NULL, + `expiresAt` integer NOT NULL, + `closedAt` integer, + `createdAt` integer NOT NULL, + FOREIGN KEY (`creatorGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `connection_spaces_tokenHash_unique` ON `connection_spaces` (`tokenHash`);--> statement-breakpoint +CREATE UNIQUE INDEX `connection_spaces_codeHash_unique` ON `connection_spaces` (`codeHash`);--> statement-breakpoint +CREATE INDEX `space_expiry` ON `connection_spaces` (`expiresAt`); \ No newline at end of file diff --git a/apps/server/drizzle/0001_numerous_dust.sql b/apps/server/drizzle/0001_numerous_dust.sql new file mode 100644 index 0000000..f73ace2 --- /dev/null +++ b/apps/server/drizzle/0001_numerous_dust.sql @@ -0,0 +1 @@ +ALTER TABLE `transfer_requests` ADD `rejectedAt` integer; \ No newline at end of file diff --git a/apps/server/drizzle/0002_orange_norrin_radd.sql b/apps/server/drizzle/0002_orange_norrin_radd.sql new file mode 100644 index 0000000..21fe65e --- /dev/null +++ b/apps/server/drizzle/0002_orange_norrin_radd.sql @@ -0,0 +1,71 @@ +CREATE TABLE `deletion_targets` ( + `id` text PRIMARY KEY NOT NULL, + `operationId` text NOT NULL, + `accessId` text NOT NULL, + FOREIGN KEY (`operationId`) REFERENCES `deletion_operations`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `deletion_operations` ( + `id` text PRIMARY KEY NOT NULL, + `guestId` text NOT NULL, + `undoUntil` integer NOT NULL, + `createdAt` integer NOT NULL, + `restoredAt` integer, + FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `files` ( + `id` text PRIMARY KEY NOT NULL, + `requestId` text NOT NULL, + `ownerGuestId` text NOT NULL, + `position` integer NOT NULL, + `storageKey` text NOT NULL, + `name` text NOT NULL, + `type` text NOT NULL, + `size` integer NOT NULL, + `checksum` text, + `status` text NOT NULL, + `uploadId` text, + `updatedAt` integer NOT NULL, + FOREIGN KEY (`requestId`) REFERENCES `transfer_requests`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`ownerGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "file_nonnegative_size" CHECK("files"."size" >= 0) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `files_storageKey_unique` ON `files` (`storageKey`);--> statement-breakpoint +CREATE UNIQUE INDEX `request_file_position` ON `files` (`requestId`,`position`);--> statement-breakpoint +CREATE INDEX `files_owner_state` ON `files` (`ownerGuestId`,`status`);--> statement-breakpoint +CREATE TABLE `quota_reservations` ( + `id` text PRIMARY KEY NOT NULL, + `fileId` text NOT NULL, + `guestId` text NOT NULL, + `bytes` integer NOT NULL, + `expiresAt` integer NOT NULL, + FOREIGN KEY (`fileId`) REFERENCES `files`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "reservation_nonnegative" CHECK("quota_reservations"."bytes" >= 0) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `quota_reservations_fileId_unique` ON `quota_reservations` (`fileId`);--> statement-breakpoint +CREATE INDEX `quota_guest_expiry` ON `quota_reservations` (`guestId`,`expiresAt`);--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_items` ( + `id` text PRIMARY KEY NOT NULL, + `batchId` text NOT NULL, + `kind` text NOT NULL, + `text` text, + `url` text, + `fileId` text, + `createdAt` integer NOT NULL, + `expiresAt` integer NOT NULL, + FOREIGN KEY (`batchId`) REFERENCES `transfer_batches`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`fileId`) REFERENCES `files`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "item_payload" CHECK(("__new_items"."kind" = 'text' AND "__new_items"."text" IS NOT NULL AND "__new_items"."url" IS NULL AND "__new_items"."fileId" IS NULL) OR ("__new_items"."kind" = 'url' AND "__new_items"."url" IS NOT NULL AND "__new_items"."text" IS NULL AND "__new_items"."fileId" IS NULL) OR ("__new_items"."kind" = 'file' AND "__new_items"."fileId" IS NOT NULL AND "__new_items"."text" IS NULL AND "__new_items"."url" IS NULL)) +); +--> statement-breakpoint +INSERT INTO `__new_items`("id", "batchId", "kind", "text", "url", "fileId", "createdAt", "expiresAt") SELECT "id", "batchId", "kind", "text", "url", NULL, "createdAt", "expiresAt" FROM `items`;--> statement-breakpoint +DROP TABLE `items`;--> statement-breakpoint +ALTER TABLE `__new_items` RENAME TO `items`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `items_expiry` ON `items` (`expiresAt`);--> statement-breakpoint +ALTER TABLE `transfer_requests` ADD `manifest` text; \ No newline at end of file diff --git a/apps/server/drizzle/0003_spooky_blob.sql b/apps/server/drizzle/0003_spooky_blob.sql new file mode 100644 index 0000000..8a0359e --- /dev/null +++ b/apps/server/drizzle/0003_spooky_blob.sql @@ -0,0 +1 @@ +ALTER TABLE `files` ADD `uploadKey` text; \ No newline at end of file diff --git a/apps/server/drizzle/0004_bright_wildside.sql b/apps/server/drizzle/0004_bright_wildside.sql new file mode 100644 index 0000000..16e7de9 --- /dev/null +++ b/apps/server/drizzle/0004_bright_wildside.sql @@ -0,0 +1,191 @@ +CREATE TABLE `account_sessions` ( + `id` text PRIMARY KEY NOT NULL, + `userId` text NOT NULL, + `tokenHash` text NOT NULL, + `createdAt` integer NOT NULL, + `lastSeen` integer NOT NULL, + `expiresAt` integer NOT NULL, + `revokedAt` integer, + FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `account_sessions_tokenHash_unique` ON `account_sessions` (`tokenHash`);--> statement-breakpoint +CREATE INDEX `account_session_user` ON `account_sessions` (`userId`,`expiresAt`);--> statement-breakpoint +CREATE TABLE `account_tokens` ( + `id` text PRIMARY KEY NOT NULL, + `userId` text NOT NULL, + `purpose` text NOT NULL, + `tokenHash` text NOT NULL, + `value` text, + `createdAt` integer NOT NULL, + `expiresAt` integer NOT NULL, + `usedAt` integer, + FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `account_tokens_tokenHash_unique` ON `account_tokens` (`tokenHash`);--> statement-breakpoint +CREATE INDEX `account_token_expiry` ON `account_tokens` (`expiresAt`);--> statement-breakpoint +CREATE TABLE `user_rules` ( + `id` text PRIMARY KEY NOT NULL, + `userId` text NOT NULL, + `targetUserId` text NOT NULL, + `action` text NOT NULL, + FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`targetUserId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "rule_not_self" CHECK("user_rules"."userId" <> "user_rules"."targetUserId") +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_rule_pair` ON `user_rules` (`userId`,`targetUserId`);--> statement-breakpoint +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `email` text, + `displayName` text NOT NULL, + `passwordHash` text, + `role` text DEFAULT 'user' NOT NULL, + `status` text NOT NULL, + `verifiedAt` integer, + `createdAt` integer NOT NULL, + `receivePolicy` text DEFAULT 'requests' NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_transfer_requests` ( + `id` text PRIMARY KEY NOT NULL, + `mode` text DEFAULT 'local' NOT NULL, + `senderGuestId` text, + `recipientGuestId` text, + `senderUserId` text, + `recipientUserId` text, + `kind` text NOT NULL, + `byteSize` integer NOT NULL, + `manifest` text, + `count` integer NOT NULL, + `status` text NOT NULL, + `createdAt` integer NOT NULL, + `expiresAt` integer NOT NULL, + `acceptedAt` integer, + `rejectedAt` integer, + `startBy` integer, + FOREIGN KEY (`senderGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`recipientGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`senderUserId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`recipientUserId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "request_different_guests" CHECK("__new_transfer_requests"."mode" <> 'local' OR "__new_transfer_requests"."senderGuestId" <> "__new_transfer_requests"."recipientGuestId"), + CONSTRAINT "request_sender_subject" CHECK(("__new_transfer_requests"."senderGuestId" IS NOT NULL) + ("__new_transfer_requests"."senderUserId" IS NOT NULL) = 1), + CONSTRAINT "request_recipient_subject" CHECK(("__new_transfer_requests"."recipientGuestId" IS NOT NULL) + ("__new_transfer_requests"."recipientUserId" IS NOT NULL) = 1), + CONSTRAINT "request_mode_subjects" CHECK(("__new_transfer_requests"."mode" = 'local' AND "__new_transfer_requests"."senderGuestId" IS NOT NULL AND "__new_transfer_requests"."recipientGuestId" IS NOT NULL) OR ("__new_transfer_requests"."mode" = 'email' AND "__new_transfer_requests"."senderUserId" IS NOT NULL AND "__new_transfer_requests"."recipientUserId" IS NOT NULL)) +); +--> statement-breakpoint +INSERT INTO `__new_transfer_requests`("id", "mode", "senderGuestId", "recipientGuestId", "senderUserId", "recipientUserId", "kind", "byteSize", "manifest", "count", "status", "createdAt", "expiresAt", "acceptedAt", "rejectedAt", "startBy") SELECT "id", "mode", "senderGuestId", "recipientGuestId", NULL, NULL, "kind", "byteSize", "manifest", "count", "status", "createdAt", "expiresAt", "acceptedAt", "rejectedAt", "startBy" FROM `transfer_requests`;--> statement-breakpoint +DROP TABLE `transfer_requests`;--> statement-breakpoint +ALTER TABLE `__new_transfer_requests` RENAME TO `transfer_requests`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `requests_sender_time` ON `transfer_requests` (`senderGuestId`,`createdAt`);--> statement-breakpoint +CREATE INDEX `requests_recipient_time` ON `transfer_requests` (`recipientGuestId`,`createdAt`);--> statement-breakpoint +CREATE INDEX `requests_expiry` ON `transfer_requests` (`status`,`expiresAt`);--> statement-breakpoint +CREATE INDEX `requests_sender_user` ON `transfer_requests` (`senderUserId`,`createdAt`);--> statement-breakpoint +CREATE INDEX `requests_recipient_user` ON `transfer_requests` (`recipientUserId`,`createdAt`);--> statement-breakpoint +CREATE TABLE `__new_item_access` ( + `id` text PRIMARY KEY NOT NULL, + `itemId` text NOT NULL, + `userId` text, + `guestId` text, + `role` text NOT NULL, + `read` integer DEFAULT false NOT NULL, + `deletedAt` integer, + `favorite` integer DEFAULT false NOT NULL, + FOREIGN KEY (`itemId`) REFERENCES `items`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "access_subject" CHECK(("__new_item_access"."guestId" IS NOT NULL) + ("__new_item_access"."userId" IS NOT NULL) = 1) +); +--> statement-breakpoint +INSERT INTO `__new_item_access`("id", "itemId", "userId", "guestId", "role", "read", "deletedAt", "favorite") SELECT "id", "itemId", NULL, "guestId", "role", "read", "deletedAt", 0 FROM `item_access`;--> statement-breakpoint +DROP TABLE `item_access`;--> statement-breakpoint +ALTER TABLE `__new_item_access` RENAME TO `item_access`;--> statement-breakpoint +CREATE UNIQUE INDEX `access_subject_role` ON `item_access` (`itemId`,`guestId`,`role`);--> statement-breakpoint +CREATE UNIQUE INDEX `access_account_role` ON `item_access` (`itemId`,`userId`,`role`);--> statement-breakpoint +CREATE INDEX `access_account` ON `item_access` (`userId`,`deletedAt`);--> statement-breakpoint +CREATE INDEX `access_guest` ON `item_access` (`guestId`,`deletedAt`);--> statement-breakpoint +CREATE TABLE `__new_deletion_operations` ( + `id` text PRIMARY KEY NOT NULL, + `userId` text, + `guestId` text, + `undoUntil` integer NOT NULL, + `createdAt` integer NOT NULL, + `restoredAt` integer, + FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "deletion_subject" CHECK(("__new_deletion_operations"."guestId" IS NOT NULL) + ("__new_deletion_operations"."userId" IS NOT NULL) = 1) +); +--> statement-breakpoint +INSERT INTO `__new_deletion_operations`("id", "userId", "guestId", "undoUntil", "createdAt", "restoredAt") SELECT "id", NULL, "guestId", "undoUntil", "createdAt", "restoredAt" FROM `deletion_operations`;--> statement-breakpoint +DROP TABLE `deletion_operations`;--> statement-breakpoint +ALTER TABLE `__new_deletion_operations` RENAME TO `deletion_operations`;--> statement-breakpoint +CREATE TABLE `__new_files` ( + `id` text PRIMARY KEY NOT NULL, + `requestId` text NOT NULL, + `ownerGuestId` text, + `ownerUserId` text, + `position` integer NOT NULL, + `storageKey` text NOT NULL, + `name` text NOT NULL, + `type` text NOT NULL, + `size` integer NOT NULL, + `checksum` text, + `status` text NOT NULL, + `uploadId` text, + `uploadKey` text, + `updatedAt` integer NOT NULL, + FOREIGN KEY (`requestId`) REFERENCES `transfer_requests`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`ownerGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`ownerUserId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "file_nonnegative_size" CHECK("__new_files"."size" >= 0), + CONSTRAINT "file_owner_subject" CHECK(("__new_files"."ownerGuestId" IS NOT NULL) + ("__new_files"."ownerUserId" IS NOT NULL) = 1) +); +--> statement-breakpoint +INSERT INTO `__new_files`("id", "requestId", "ownerGuestId", "ownerUserId", "position", "storageKey", "name", "type", "size", "checksum", "status", "uploadId", "uploadKey", "updatedAt") SELECT "id", "requestId", "ownerGuestId", NULL, "position", "storageKey", "name", "type", "size", "checksum", "status", "uploadId", "uploadKey", "updatedAt" FROM `files`;--> statement-breakpoint +DROP TABLE `files`;--> statement-breakpoint +ALTER TABLE `__new_files` RENAME TO `files`;--> statement-breakpoint +CREATE UNIQUE INDEX `files_storageKey_unique` ON `files` (`storageKey`);--> statement-breakpoint +CREATE UNIQUE INDEX `request_file_position` ON `files` (`requestId`,`position`);--> statement-breakpoint +CREATE INDEX `files_owner_state` ON `files` (`ownerGuestId`,`status`);--> statement-breakpoint +CREATE TABLE `__new_idempotency_records` ( + `id` text PRIMARY KEY NOT NULL, + `userId` text, + `guestId` text, + `operation` text NOT NULL, + `key` text NOT NULL, + `payloadHash` text NOT NULL, + `response` text NOT NULL, + `createdAt` integer NOT NULL, + FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "idempotency_subject" CHECK(("__new_idempotency_records"."guestId" IS NOT NULL) + ("__new_idempotency_records"."userId" IS NOT NULL) = 1) +); +--> statement-breakpoint +INSERT INTO `__new_idempotency_records`("id", "userId", "guestId", "operation", "key", "payloadHash", "response", "createdAt") SELECT "id", NULL, "guestId", "operation", "key", "payloadHash", "response", "createdAt" FROM `idempotency_records`;--> statement-breakpoint +DROP TABLE `idempotency_records`;--> statement-breakpoint +ALTER TABLE `__new_idempotency_records` RENAME TO `idempotency_records`;--> statement-breakpoint +CREATE UNIQUE INDEX `idempotency_scope` ON `idempotency_records` (`guestId`,`operation`,`key`);--> statement-breakpoint +CREATE UNIQUE INDEX `account_idempotency_scope` ON `idempotency_records` (`userId`,`operation`,`key`);--> statement-breakpoint +CREATE TABLE `__new_quota_reservations` ( + `id` text PRIMARY KEY NOT NULL, + `fileId` text NOT NULL, + `userId` text, + `guestId` text, + `bytes` integer NOT NULL, + `expiresAt` integer NOT NULL, + FOREIGN KEY (`fileId`) REFERENCES `files`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "reservations_subject" CHECK(("__new_quota_reservations"."guestId" IS NOT NULL) + ("__new_quota_reservations"."userId" IS NOT NULL) = 1), + CONSTRAINT "reservation_nonnegative" CHECK("__new_quota_reservations"."bytes" >= 0) +); +--> statement-breakpoint +INSERT INTO `__new_quota_reservations`("id", "fileId", "userId", "guestId", "bytes", "expiresAt") SELECT "id", "fileId", NULL, "guestId", "bytes", "expiresAt" FROM `quota_reservations`;--> statement-breakpoint +DROP TABLE `quota_reservations`;--> statement-breakpoint +ALTER TABLE `__new_quota_reservations` RENAME TO `quota_reservations`;--> statement-breakpoint +CREATE UNIQUE INDEX `quota_reservations_fileId_unique` ON `quota_reservations` (`fileId`);--> statement-breakpoint +CREATE INDEX `quota_guest_expiry` ON `quota_reservations` (`guestId`,`expiresAt`); \ No newline at end of file diff --git a/apps/server/drizzle/0005_tan_miracleman.sql b/apps/server/drizzle/0005_tan_miracleman.sql new file mode 100644 index 0000000..64fe306 --- /dev/null +++ b/apps/server/drizzle/0005_tan_miracleman.sql @@ -0,0 +1,17 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_deletion_operations` ( + `id` text PRIMARY KEY NOT NULL, + `userId` text, + `guestId` text, + `undoUntil` integer NOT NULL, + `createdAt` integer NOT NULL, + `restoredAt` integer, + FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "deletion_subject" CHECK(("__new_deletion_operations"."guestId" IS NOT NULL) + ("__new_deletion_operations"."userId" IS NOT NULL) = 1) +); +--> statement-breakpoint +INSERT INTO `__new_deletion_operations`("id", "userId", "guestId", "undoUntil", "createdAt", "restoredAt") SELECT "id", "userId", "guestId", "undoUntil", "createdAt", "restoredAt" FROM `deletion_operations`;--> statement-breakpoint +DROP TABLE `deletion_operations`;--> statement-breakpoint +ALTER TABLE `__new_deletion_operations` RENAME TO `deletion_operations`;--> statement-breakpoint +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/apps/server/drizzle/0006_file_url_only.sql b/apps/server/drizzle/0006_file_url_only.sql new file mode 100644 index 0000000..5c34d93 --- /dev/null +++ b/apps/server/drizzle/0006_file_url_only.sql @@ -0,0 +1,3 @@ +UPDATE transfer_requests SET status = 'cancelled' WHERE kind = 'text' AND status IN ('pending','accepted') AND EXISTS (SELECT 1 FROM transfer_batches b WHERE b.requestId = transfer_requests.id AND b.status IN ('waiting','uploading')); +--> statement-breakpoint +UPDATE transfer_batches SET status = 'failed' WHERE status IN ('waiting','uploading') AND EXISTS (SELECT 1 FROM transfer_requests r WHERE r.id = transfer_batches.requestId AND r.kind = 'text'); diff --git a/apps/server/drizzle/0007_rapid_morph.sql b/apps/server/drizzle/0007_rapid_morph.sql new file mode 100644 index 0000000..284cb5f --- /dev/null +++ b/apps/server/drizzle/0007_rapid_morph.sql @@ -0,0 +1,21 @@ +CREATE TABLE `request_receipt_accounts` ( + `requestId` text PRIMARY KEY NOT NULL, + `userId` text NOT NULL, + `sessionId` text NOT NULL, + FOREIGN KEY (`requestId`) REFERENCES `transfer_requests`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`sessionId`) REFERENCES `account_sessions`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `vault_transfers` ( + `requestId` text PRIMARY KEY NOT NULL, + `accessId` text NOT NULL, + `userId` text NOT NULL, + `sessionId` text NOT NULL, + FOREIGN KEY (`requestId`) REFERENCES `transfer_requests`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`accessId`) REFERENCES `item_access`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`sessionId`) REFERENCES `account_sessions`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +ALTER TABLE `item_access` ADD `note` text; \ No newline at end of file diff --git a/apps/server/drizzle/0008_temporary-retention.sql b/apps/server/drizzle/0008_temporary-retention.sql new file mode 100644 index 0000000..b6f80eb --- /dev/null +++ b/apps/server/drizzle/0008_temporary-retention.sql @@ -0,0 +1,10 @@ +-- Custom SQL migration file, put your code below! -- +UPDATE items SET expiresAt = min(expiresAt, CAST(strftime('%s', 'now') AS INTEGER) * 1000 + 604800000); +--> statement-breakpoint +UPDATE item_access SET favorite = 0; +--> statement-breakpoint +UPDATE transfer_requests SET status = 'cancelled' WHERE mode = 'email' AND senderUserId <> recipientUserId AND status IN ('pending', 'accepted') AND id IN (SELECT requestId FROM transfer_batches WHERE status <> 'stored'); +--> statement-breakpoint +UPDATE transfer_batches SET status = 'failed' WHERE requestId IN (SELECT id FROM transfer_requests WHERE mode = 'email' AND senderUserId <> recipientUserId AND status = 'cancelled'); +--> statement-breakpoint +DELETE FROM quota_reservations WHERE fileId IN (SELECT files.id FROM files INNER JOIN transfer_requests ON files.requestId = transfer_requests.id WHERE transfer_requests.mode = 'email' AND transfer_requests.senderUserId <> transfer_requests.recipientUserId AND transfer_requests.status = 'cancelled'); diff --git a/apps/server/drizzle/0009_vault-note.sql b/apps/server/drizzle/0009_vault-note.sql new file mode 100644 index 0000000..4adff93 --- /dev/null +++ b/apps/server/drizzle/0009_vault-note.sql @@ -0,0 +1 @@ +ALTER TABLE `transfer_requests` ADD `note` text; \ No newline at end of file diff --git a/apps/server/drizzle/0010_single-room.sql b/apps/server/drizzle/0010_single-room.sql new file mode 100644 index 0000000..300da74 --- /dev/null +++ b/apps/server/drizzle/0010_single-room.sql @@ -0,0 +1,7 @@ +UPDATE space_members SET status = 'removed' WHERE spaceId IN (SELECT id FROM connection_spaces WHERE closedAt IS NOT NULL OR expiresAt <= CAST(strftime('%s','now') AS INTEGER)*1000); +--> statement-breakpoint +UPDATE space_members AS member SET status = 'removed' WHERE status IN ('pending','approved') AND EXISTS (SELECT 1 FROM space_members newer WHERE newer.deviceId = member.deviceId AND newer.status IN ('pending','approved') AND (newer.joinedAt > member.joinedAt OR (newer.joinedAt = member.joinedAt AND newer.id > member.id))); +--> statement-breakpoint +UPDATE connection_spaces SET closedAt = CAST(strftime('%s','now') AS INTEGER)*1000 WHERE closedAt IS NULL AND NOT EXISTS (SELECT 1 FROM space_members m INNER JOIN browser_devices d ON d.id = m.deviceId WHERE m.spaceId = connection_spaces.id AND m.status = 'approved' AND d.guestId = connection_spaces.creatorGuestId); +--> statement-breakpoint +UPDATE space_members SET status = 'removed' WHERE spaceId IN (SELECT id FROM connection_spaces WHERE closedAt IS NOT NULL); diff --git a/apps/server/drizzle/0011_single-room-index.sql b/apps/server/drizzle/0011_single-room-index.sql new file mode 100644 index 0000000..9e33bac --- /dev/null +++ b/apps/server/drizzle/0011_single-room-index.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX `member_single_active_device` ON `space_members` (`deviceId`) WHERE "space_members"."status" IN ('pending', 'approved'); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0000_snapshot.json b/apps/server/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..53af7f3 --- /dev/null +++ b/apps/server/drizzle/meta/0000_snapshot.json @@ -0,0 +1,894 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "11892dda-406f-413c-ba07-9b439d0549db", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "item_access": { + "name": "item_access", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "itemId": { + "name": "itemId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "access_subject_role": { + "name": "access_subject_role", + "columns": [ + "itemId", + "guestId", + "role" + ], + "isUnique": true + }, + "access_guest": { + "name": "access_guest", + "columns": [ + "guestId", + "deletedAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "item_access_itemId_items_id_fk": { + "name": "item_access_itemId_items_id_fk", + "tableFrom": "item_access", + "tableTo": "items", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_guestId_guest_sessions_id_fk": { + "name": "item_access_guestId_guest_sessions_id_fk", + "tableFrom": "item_access", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_batches": { + "name": "transfer_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storedAt": { + "name": "storedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "transfer_batches_requestId_unique": { + "name": "transfer_batches_requestId_unique", + "columns": [ + "requestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "transfer_batches_requestId_transfer_requests_id_fk": { + "name": "transfer_batches_requestId_transfer_requests_id_fk", + "tableFrom": "transfer_batches", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guest_blocks": { + "name": "guest_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetGuestId": { + "name": "targetGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_block_pair": { + "name": "guest_block_pair", + "columns": [ + "guestId", + "targetGuestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guest_blocks_guestId_guest_sessions_id_fk": { + "name": "guest_blocks_guestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "guest_blocks_targetGuestId_guest_sessions_id_fk": { + "name": "guest_blocks_targetGuestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "targetGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_devices": { + "name": "browser_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discoverable": { + "name": "discoverable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_devices_guestId_unique": { + "name": "browser_devices_guestId_unique", + "columns": [ + "guestId" + ], + "isUnique": true + }, + "browser_devices_code_unique": { + "name": "browser_devices_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "browser_devices_guestId_guest_sessions_id_fk": { + "name": "browser_devices_guestId_guest_sessions_id_fk", + "tableFrom": "browser_devices", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "device_name_length": { + "name": "device_name_length", + "value": "length(\"browser_devices\".\"name\") between 1 and 40" + } + } + }, + "guest_sessions": { + "name": "guest_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_sessions_tokenHash_unique": { + "name": "guest_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "guest_expiry": { + "name": "guest_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idempotency_records": { + "name": "idempotency_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payloadHash": { + "name": "payloadHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idempotency_scope": { + "name": "idempotency_scope", + "columns": [ + "guestId", + "operation", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "idempotency_records_guestId_guest_sessions_id_fk": { + "name": "idempotency_records_guestId_guest_sessions_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "batchId": { + "name": "batchId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "items_expiry": { + "name": "items_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "items_batchId_transfer_batches_id_fk": { + "name": "items_batchId_transfer_batches_id_fk", + "tableFrom": "items", + "tableTo": "transfer_batches", + "columnsFrom": [ + "batchId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "item_payload": { + "name": "item_payload", + "value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL)" + } + } + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joinedAt": { + "name": "joinedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "space_device": { + "name": "space_device", + "columns": [ + "spaceId", + "deviceId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "space_members_spaceId_connection_spaces_id_fk": { + "name": "space_members_spaceId_connection_spaces_id_fk", + "tableFrom": "space_members", + "tableTo": "connection_spaces", + "columnsFrom": [ + "spaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "space_members_deviceId_browser_devices_id_fk": { + "name": "space_members_deviceId_browser_devices_id_fk", + "tableFrom": "space_members", + "tableTo": "browser_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_requests": { + "name": "transfer_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "senderGuestId": { + "name": "senderGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipientGuestId": { + "name": "recipientGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byteSize": { + "name": "byteSize", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startBy": { + "name": "startBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "requests_sender_time": { + "name": "requests_sender_time", + "columns": [ + "senderGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_time": { + "name": "requests_recipient_time", + "columns": [ + "recipientGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_expiry": { + "name": "requests_expiry", + "columns": [ + "status", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_requests_senderGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_senderGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "senderGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_recipientGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "recipientGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "request_different_guests": { + "name": "request_different_guests", + "value": "\"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\"" + } + } + }, + "connection_spaces": { + "name": "connection_spaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "creatorGuestId": { + "name": "creatorGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approvalRequired": { + "name": "approvalRequired", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closedAt": { + "name": "closedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_spaces_tokenHash_unique": { + "name": "connection_spaces_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "connection_spaces_codeHash_unique": { + "name": "connection_spaces_codeHash_unique", + "columns": [ + "codeHash" + ], + "isUnique": true + }, + "space_expiry": { + "name": "space_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "connection_spaces_creatorGuestId_guest_sessions_id_fk": { + "name": "connection_spaces_creatorGuestId_guest_sessions_id_fk", + "tableFrom": "connection_spaces", + "tableTo": "guest_sessions", + "columnsFrom": [ + "creatorGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/0001_snapshot.json b/apps/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..fec961d --- /dev/null +++ b/apps/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,901 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "583c8a7b-3fe7-47d9-b150-a93d5a7d8de3", + "prevId": "11892dda-406f-413c-ba07-9b439d0549db", + "tables": { + "item_access": { + "name": "item_access", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "itemId": { + "name": "itemId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "access_subject_role": { + "name": "access_subject_role", + "columns": [ + "itemId", + "guestId", + "role" + ], + "isUnique": true + }, + "access_guest": { + "name": "access_guest", + "columns": [ + "guestId", + "deletedAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "item_access_itemId_items_id_fk": { + "name": "item_access_itemId_items_id_fk", + "tableFrom": "item_access", + "tableTo": "items", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_guestId_guest_sessions_id_fk": { + "name": "item_access_guestId_guest_sessions_id_fk", + "tableFrom": "item_access", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_batches": { + "name": "transfer_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storedAt": { + "name": "storedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "transfer_batches_requestId_unique": { + "name": "transfer_batches_requestId_unique", + "columns": [ + "requestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "transfer_batches_requestId_transfer_requests_id_fk": { + "name": "transfer_batches_requestId_transfer_requests_id_fk", + "tableFrom": "transfer_batches", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guest_blocks": { + "name": "guest_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetGuestId": { + "name": "targetGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_block_pair": { + "name": "guest_block_pair", + "columns": [ + "guestId", + "targetGuestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guest_blocks_guestId_guest_sessions_id_fk": { + "name": "guest_blocks_guestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "guest_blocks_targetGuestId_guest_sessions_id_fk": { + "name": "guest_blocks_targetGuestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "targetGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_devices": { + "name": "browser_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discoverable": { + "name": "discoverable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_devices_guestId_unique": { + "name": "browser_devices_guestId_unique", + "columns": [ + "guestId" + ], + "isUnique": true + }, + "browser_devices_code_unique": { + "name": "browser_devices_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "browser_devices_guestId_guest_sessions_id_fk": { + "name": "browser_devices_guestId_guest_sessions_id_fk", + "tableFrom": "browser_devices", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "device_name_length": { + "name": "device_name_length", + "value": "length(\"browser_devices\".\"name\") between 1 and 40" + } + } + }, + "guest_sessions": { + "name": "guest_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_sessions_tokenHash_unique": { + "name": "guest_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "guest_expiry": { + "name": "guest_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idempotency_records": { + "name": "idempotency_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payloadHash": { + "name": "payloadHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idempotency_scope": { + "name": "idempotency_scope", + "columns": [ + "guestId", + "operation", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "idempotency_records_guestId_guest_sessions_id_fk": { + "name": "idempotency_records_guestId_guest_sessions_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "batchId": { + "name": "batchId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "items_expiry": { + "name": "items_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "items_batchId_transfer_batches_id_fk": { + "name": "items_batchId_transfer_batches_id_fk", + "tableFrom": "items", + "tableTo": "transfer_batches", + "columnsFrom": [ + "batchId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "item_payload": { + "name": "item_payload", + "value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL)" + } + } + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joinedAt": { + "name": "joinedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "space_device": { + "name": "space_device", + "columns": [ + "spaceId", + "deviceId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "space_members_spaceId_connection_spaces_id_fk": { + "name": "space_members_spaceId_connection_spaces_id_fk", + "tableFrom": "space_members", + "tableTo": "connection_spaces", + "columnsFrom": [ + "spaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "space_members_deviceId_browser_devices_id_fk": { + "name": "space_members_deviceId_browser_devices_id_fk", + "tableFrom": "space_members", + "tableTo": "browser_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_requests": { + "name": "transfer_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "senderGuestId": { + "name": "senderGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipientGuestId": { + "name": "recipientGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byteSize": { + "name": "byteSize", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejectedAt": { + "name": "rejectedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startBy": { + "name": "startBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "requests_sender_time": { + "name": "requests_sender_time", + "columns": [ + "senderGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_time": { + "name": "requests_recipient_time", + "columns": [ + "recipientGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_expiry": { + "name": "requests_expiry", + "columns": [ + "status", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_requests_senderGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_senderGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "senderGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_recipientGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "recipientGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "request_different_guests": { + "name": "request_different_guests", + "value": "\"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\"" + } + } + }, + "connection_spaces": { + "name": "connection_spaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "creatorGuestId": { + "name": "creatorGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approvalRequired": { + "name": "approvalRequired", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closedAt": { + "name": "closedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_spaces_tokenHash_unique": { + "name": "connection_spaces_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "connection_spaces_codeHash_unique": { + "name": "connection_spaces_codeHash_unique", + "columns": [ + "codeHash" + ], + "isUnique": true + }, + "space_expiry": { + "name": "space_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "connection_spaces_creatorGuestId_guest_sessions_id_fk": { + "name": "connection_spaces_creatorGuestId_guest_sessions_id_fk", + "tableFrom": "connection_spaces", + "tableTo": "guest_sessions", + "columnsFrom": [ + "creatorGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/0002_snapshot.json b/apps/server/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..76c5f27 --- /dev/null +++ b/apps/server/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1275 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "685081b3-5c95-48c0-a954-76041d00fac5", + "prevId": "583c8a7b-3fe7-47d9-b150-a93d5a7d8de3", + "tables": { + "item_access": { + "name": "item_access", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "itemId": { + "name": "itemId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "access_subject_role": { + "name": "access_subject_role", + "columns": [ + "itemId", + "guestId", + "role" + ], + "isUnique": true + }, + "access_guest": { + "name": "access_guest", + "columns": [ + "guestId", + "deletedAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "item_access_itemId_items_id_fk": { + "name": "item_access_itemId_items_id_fk", + "tableFrom": "item_access", + "tableTo": "items", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_guestId_guest_sessions_id_fk": { + "name": "item_access_guestId_guest_sessions_id_fk", + "tableFrom": "item_access", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_batches": { + "name": "transfer_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storedAt": { + "name": "storedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "transfer_batches_requestId_unique": { + "name": "transfer_batches_requestId_unique", + "columns": [ + "requestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "transfer_batches_requestId_transfer_requests_id_fk": { + "name": "transfer_batches_requestId_transfer_requests_id_fk", + "tableFrom": "transfer_batches", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guest_blocks": { + "name": "guest_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetGuestId": { + "name": "targetGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_block_pair": { + "name": "guest_block_pair", + "columns": [ + "guestId", + "targetGuestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guest_blocks_guestId_guest_sessions_id_fk": { + "name": "guest_blocks_guestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "guest_blocks_targetGuestId_guest_sessions_id_fk": { + "name": "guest_blocks_targetGuestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "targetGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_targets": { + "name": "deletion_targets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operationId": { + "name": "operationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessId": { + "name": "accessId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_targets_operationId_deletion_operations_id_fk": { + "name": "deletion_targets_operationId_deletion_operations_id_fk", + "tableFrom": "deletion_targets", + "tableTo": "deletion_operations", + "columnsFrom": [ + "operationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_operations": { + "name": "deletion_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "undoUntil": { + "name": "undoUntil", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "restoredAt": { + "name": "restoredAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_operations_guestId_guest_sessions_id_fk": { + "name": "deletion_operations_guestId_guest_sessions_id_fk", + "tableFrom": "deletion_operations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_devices": { + "name": "browser_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discoverable": { + "name": "discoverable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_devices_guestId_unique": { + "name": "browser_devices_guestId_unique", + "columns": [ + "guestId" + ], + "isUnique": true + }, + "browser_devices_code_unique": { + "name": "browser_devices_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "browser_devices_guestId_guest_sessions_id_fk": { + "name": "browser_devices_guestId_guest_sessions_id_fk", + "tableFrom": "browser_devices", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "device_name_length": { + "name": "device_name_length", + "value": "length(\"browser_devices\".\"name\") between 1 and 40" + } + } + }, + "files": { + "name": "files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerGuestId": { + "name": "ownerGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storageKey": { + "name": "storageKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadId": { + "name": "uploadId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "files_storageKey_unique": { + "name": "files_storageKey_unique", + "columns": [ + "storageKey" + ], + "isUnique": true + }, + "request_file_position": { + "name": "request_file_position", + "columns": [ + "requestId", + "position" + ], + "isUnique": true + }, + "files_owner_state": { + "name": "files_owner_state", + "columns": [ + "ownerGuestId", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "files_requestId_transfer_requests_id_fk": { + "name": "files_requestId_transfer_requests_id_fk", + "tableFrom": "files", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_ownerGuestId_guest_sessions_id_fk": { + "name": "files_ownerGuestId_guest_sessions_id_fk", + "tableFrom": "files", + "tableTo": "guest_sessions", + "columnsFrom": [ + "ownerGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "file_nonnegative_size": { + "name": "file_nonnegative_size", + "value": "\"files\".\"size\" >= 0" + } + } + }, + "guest_sessions": { + "name": "guest_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_sessions_tokenHash_unique": { + "name": "guest_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "guest_expiry": { + "name": "guest_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idempotency_records": { + "name": "idempotency_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payloadHash": { + "name": "payloadHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idempotency_scope": { + "name": "idempotency_scope", + "columns": [ + "guestId", + "operation", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "idempotency_records_guestId_guest_sessions_id_fk": { + "name": "idempotency_records_guestId_guest_sessions_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "batchId": { + "name": "batchId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "items_expiry": { + "name": "items_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "items_batchId_transfer_batches_id_fk": { + "name": "items_batchId_transfer_batches_id_fk", + "tableFrom": "items", + "tableTo": "transfer_batches", + "columnsFrom": [ + "batchId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "items_fileId_files_id_fk": { + "name": "items_fileId_files_id_fk", + "tableFrom": "items", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "item_payload": { + "name": "item_payload", + "value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'file' AND \"items\".\"fileId\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"url\" IS NULL)" + } + } + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joinedAt": { + "name": "joinedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "space_device": { + "name": "space_device", + "columns": [ + "spaceId", + "deviceId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "space_members_spaceId_connection_spaces_id_fk": { + "name": "space_members_spaceId_connection_spaces_id_fk", + "tableFrom": "space_members", + "tableTo": "connection_spaces", + "columnsFrom": [ + "spaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "space_members_deviceId_browser_devices_id_fk": { + "name": "space_members_deviceId_browser_devices_id_fk", + "tableFrom": "space_members", + "tableTo": "browser_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_requests": { + "name": "transfer_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "senderGuestId": { + "name": "senderGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipientGuestId": { + "name": "recipientGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byteSize": { + "name": "byteSize", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejectedAt": { + "name": "rejectedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startBy": { + "name": "startBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "requests_sender_time": { + "name": "requests_sender_time", + "columns": [ + "senderGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_time": { + "name": "requests_recipient_time", + "columns": [ + "recipientGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_expiry": { + "name": "requests_expiry", + "columns": [ + "status", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_requests_senderGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_senderGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "senderGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_recipientGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "recipientGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "request_different_guests": { + "name": "request_different_guests", + "value": "\"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\"" + } + } + }, + "quota_reservations": { + "name": "quota_reservations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "quota_reservations_fileId_unique": { + "name": "quota_reservations_fileId_unique", + "columns": [ + "fileId" + ], + "isUnique": true + }, + "quota_guest_expiry": { + "name": "quota_guest_expiry", + "columns": [ + "guestId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "quota_reservations_fileId_files_id_fk": { + "name": "quota_reservations_fileId_files_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quota_reservations_guestId_guest_sessions_id_fk": { + "name": "quota_reservations_guestId_guest_sessions_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "reservation_nonnegative": { + "name": "reservation_nonnegative", + "value": "\"quota_reservations\".\"bytes\" >= 0" + } + } + }, + "connection_spaces": { + "name": "connection_spaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "creatorGuestId": { + "name": "creatorGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approvalRequired": { + "name": "approvalRequired", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closedAt": { + "name": "closedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_spaces_tokenHash_unique": { + "name": "connection_spaces_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "connection_spaces_codeHash_unique": { + "name": "connection_spaces_codeHash_unique", + "columns": [ + "codeHash" + ], + "isUnique": true + }, + "space_expiry": { + "name": "space_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "connection_spaces_creatorGuestId_guest_sessions_id_fk": { + "name": "connection_spaces_creatorGuestId_guest_sessions_id_fk", + "tableFrom": "connection_spaces", + "tableTo": "guest_sessions", + "columnsFrom": [ + "creatorGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/0003_snapshot.json b/apps/server/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..cf803f4 --- /dev/null +++ b/apps/server/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1282 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7a8a43d6-acae-4f0f-9f6d-2d0bcb0df3cf", + "prevId": "685081b3-5c95-48c0-a954-76041d00fac5", + "tables": { + "item_access": { + "name": "item_access", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "itemId": { + "name": "itemId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "access_subject_role": { + "name": "access_subject_role", + "columns": [ + "itemId", + "guestId", + "role" + ], + "isUnique": true + }, + "access_guest": { + "name": "access_guest", + "columns": [ + "guestId", + "deletedAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "item_access_itemId_items_id_fk": { + "name": "item_access_itemId_items_id_fk", + "tableFrom": "item_access", + "tableTo": "items", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_guestId_guest_sessions_id_fk": { + "name": "item_access_guestId_guest_sessions_id_fk", + "tableFrom": "item_access", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_batches": { + "name": "transfer_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storedAt": { + "name": "storedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "transfer_batches_requestId_unique": { + "name": "transfer_batches_requestId_unique", + "columns": [ + "requestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "transfer_batches_requestId_transfer_requests_id_fk": { + "name": "transfer_batches_requestId_transfer_requests_id_fk", + "tableFrom": "transfer_batches", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guest_blocks": { + "name": "guest_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetGuestId": { + "name": "targetGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_block_pair": { + "name": "guest_block_pair", + "columns": [ + "guestId", + "targetGuestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guest_blocks_guestId_guest_sessions_id_fk": { + "name": "guest_blocks_guestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "guest_blocks_targetGuestId_guest_sessions_id_fk": { + "name": "guest_blocks_targetGuestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "targetGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_targets": { + "name": "deletion_targets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operationId": { + "name": "operationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessId": { + "name": "accessId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_targets_operationId_deletion_operations_id_fk": { + "name": "deletion_targets_operationId_deletion_operations_id_fk", + "tableFrom": "deletion_targets", + "tableTo": "deletion_operations", + "columnsFrom": [ + "operationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_operations": { + "name": "deletion_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "undoUntil": { + "name": "undoUntil", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "restoredAt": { + "name": "restoredAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_operations_guestId_guest_sessions_id_fk": { + "name": "deletion_operations_guestId_guest_sessions_id_fk", + "tableFrom": "deletion_operations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_devices": { + "name": "browser_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discoverable": { + "name": "discoverable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_devices_guestId_unique": { + "name": "browser_devices_guestId_unique", + "columns": [ + "guestId" + ], + "isUnique": true + }, + "browser_devices_code_unique": { + "name": "browser_devices_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "browser_devices_guestId_guest_sessions_id_fk": { + "name": "browser_devices_guestId_guest_sessions_id_fk", + "tableFrom": "browser_devices", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "device_name_length": { + "name": "device_name_length", + "value": "length(\"browser_devices\".\"name\") between 1 and 40" + } + } + }, + "files": { + "name": "files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerGuestId": { + "name": "ownerGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storageKey": { + "name": "storageKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadId": { + "name": "uploadId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadKey": { + "name": "uploadKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "files_storageKey_unique": { + "name": "files_storageKey_unique", + "columns": [ + "storageKey" + ], + "isUnique": true + }, + "request_file_position": { + "name": "request_file_position", + "columns": [ + "requestId", + "position" + ], + "isUnique": true + }, + "files_owner_state": { + "name": "files_owner_state", + "columns": [ + "ownerGuestId", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "files_requestId_transfer_requests_id_fk": { + "name": "files_requestId_transfer_requests_id_fk", + "tableFrom": "files", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_ownerGuestId_guest_sessions_id_fk": { + "name": "files_ownerGuestId_guest_sessions_id_fk", + "tableFrom": "files", + "tableTo": "guest_sessions", + "columnsFrom": [ + "ownerGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "file_nonnegative_size": { + "name": "file_nonnegative_size", + "value": "\"files\".\"size\" >= 0" + } + } + }, + "guest_sessions": { + "name": "guest_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_sessions_tokenHash_unique": { + "name": "guest_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "guest_expiry": { + "name": "guest_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idempotency_records": { + "name": "idempotency_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payloadHash": { + "name": "payloadHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idempotency_scope": { + "name": "idempotency_scope", + "columns": [ + "guestId", + "operation", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "idempotency_records_guestId_guest_sessions_id_fk": { + "name": "idempotency_records_guestId_guest_sessions_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "batchId": { + "name": "batchId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "items_expiry": { + "name": "items_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "items_batchId_transfer_batches_id_fk": { + "name": "items_batchId_transfer_batches_id_fk", + "tableFrom": "items", + "tableTo": "transfer_batches", + "columnsFrom": [ + "batchId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "items_fileId_files_id_fk": { + "name": "items_fileId_files_id_fk", + "tableFrom": "items", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "item_payload": { + "name": "item_payload", + "value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'file' AND \"items\".\"fileId\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"url\" IS NULL)" + } + } + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joinedAt": { + "name": "joinedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "space_device": { + "name": "space_device", + "columns": [ + "spaceId", + "deviceId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "space_members_spaceId_connection_spaces_id_fk": { + "name": "space_members_spaceId_connection_spaces_id_fk", + "tableFrom": "space_members", + "tableTo": "connection_spaces", + "columnsFrom": [ + "spaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "space_members_deviceId_browser_devices_id_fk": { + "name": "space_members_deviceId_browser_devices_id_fk", + "tableFrom": "space_members", + "tableTo": "browser_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_requests": { + "name": "transfer_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "senderGuestId": { + "name": "senderGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipientGuestId": { + "name": "recipientGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byteSize": { + "name": "byteSize", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejectedAt": { + "name": "rejectedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startBy": { + "name": "startBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "requests_sender_time": { + "name": "requests_sender_time", + "columns": [ + "senderGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_time": { + "name": "requests_recipient_time", + "columns": [ + "recipientGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_expiry": { + "name": "requests_expiry", + "columns": [ + "status", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_requests_senderGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_senderGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "senderGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_recipientGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "recipientGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "request_different_guests": { + "name": "request_different_guests", + "value": "\"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\"" + } + } + }, + "quota_reservations": { + "name": "quota_reservations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "quota_reservations_fileId_unique": { + "name": "quota_reservations_fileId_unique", + "columns": [ + "fileId" + ], + "isUnique": true + }, + "quota_guest_expiry": { + "name": "quota_guest_expiry", + "columns": [ + "guestId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "quota_reservations_fileId_files_id_fk": { + "name": "quota_reservations_fileId_files_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quota_reservations_guestId_guest_sessions_id_fk": { + "name": "quota_reservations_guestId_guest_sessions_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "reservation_nonnegative": { + "name": "reservation_nonnegative", + "value": "\"quota_reservations\".\"bytes\" >= 0" + } + } + }, + "connection_spaces": { + "name": "connection_spaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "creatorGuestId": { + "name": "creatorGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approvalRequired": { + "name": "approvalRequired", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closedAt": { + "name": "closedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_spaces_tokenHash_unique": { + "name": "connection_spaces_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "connection_spaces_codeHash_unique": { + "name": "connection_spaces_codeHash_unique", + "columns": [ + "codeHash" + ], + "isUnique": true + }, + "space_expiry": { + "name": "space_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "connection_spaces_creatorGuestId_guest_sessions_id_fk": { + "name": "connection_spaces_creatorGuestId_guest_sessions_id_fk", + "tableFrom": "connection_spaces", + "tableTo": "guest_sessions", + "columnsFrom": [ + "creatorGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/0004_snapshot.json b/apps/server/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..634ce1d --- /dev/null +++ b/apps/server/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1853 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9c096d91-489e-45c4-9a23-48176623283d", + "prevId": "7a8a43d6-acae-4f0f-9f6d-2d0bcb0df3cf", + "tables": { + "item_access": { + "name": "item_access", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "itemId": { + "name": "itemId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favorite": { + "name": "favorite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "access_subject_role": { + "name": "access_subject_role", + "columns": [ + "itemId", + "guestId", + "role" + ], + "isUnique": true + }, + "access_account_role": { + "name": "access_account_role", + "columns": [ + "itemId", + "userId", + "role" + ], + "isUnique": true + }, + "access_account": { + "name": "access_account", + "columns": [ + "userId", + "deletedAt" + ], + "isUnique": false + }, + "access_guest": { + "name": "access_guest", + "columns": [ + "guestId", + "deletedAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "item_access_itemId_items_id_fk": { + "name": "item_access_itemId_items_id_fk", + "tableFrom": "item_access", + "tableTo": "items", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_userId_users_id_fk": { + "name": "item_access_userId_users_id_fk", + "tableFrom": "item_access", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_guestId_guest_sessions_id_fk": { + "name": "item_access_guestId_guest_sessions_id_fk", + "tableFrom": "item_access", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "access_subject": { + "name": "access_subject", + "value": "(\"item_access\".\"guestId\" IS NOT NULL) + (\"item_access\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "account_sessions": { + "name": "account_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_sessions_tokenHash_unique": { + "name": "account_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_session_user": { + "name": "account_session_user", + "columns": [ + "userId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_sessions_userId_users_id_fk": { + "name": "account_sessions_userId_users_id_fk", + "tableFrom": "account_sessions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account_tokens": { + "name": "account_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_tokens_tokenHash_unique": { + "name": "account_tokens_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_token_expiry": { + "name": "account_token_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_tokens_userId_users_id_fk": { + "name": "account_tokens_userId_users_id_fk", + "tableFrom": "account_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_batches": { + "name": "transfer_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storedAt": { + "name": "storedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "transfer_batches_requestId_unique": { + "name": "transfer_batches_requestId_unique", + "columns": [ + "requestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "transfer_batches_requestId_transfer_requests_id_fk": { + "name": "transfer_batches_requestId_transfer_requests_id_fk", + "tableFrom": "transfer_batches", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guest_blocks": { + "name": "guest_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetGuestId": { + "name": "targetGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_block_pair": { + "name": "guest_block_pair", + "columns": [ + "guestId", + "targetGuestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guest_blocks_guestId_guest_sessions_id_fk": { + "name": "guest_blocks_guestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "guest_blocks_targetGuestId_guest_sessions_id_fk": { + "name": "guest_blocks_targetGuestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "targetGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_targets": { + "name": "deletion_targets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operationId": { + "name": "operationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessId": { + "name": "accessId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_targets_operationId_deletion_operations_id_fk": { + "name": "deletion_targets_operationId_deletion_operations_id_fk", + "tableFrom": "deletion_targets", + "tableTo": "deletion_operations", + "columnsFrom": [ + "operationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_operations": { + "name": "deletion_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "undoUntil": { + "name": "undoUntil", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "restoredAt": { + "name": "restoredAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_operations_userId_users_id_fk": { + "name": "deletion_operations_userId_users_id_fk", + "tableFrom": "deletion_operations", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deletion_operations_guestId_guest_sessions_id_fk": { + "name": "deletion_operations_guestId_guest_sessions_id_fk", + "tableFrom": "deletion_operations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "deletion_subject": { + "name": "deletion_subject", + "value": "(\"deletion_operations\".\"guestId\" IS NOT NULL) + (\"deletion_operations\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "browser_devices": { + "name": "browser_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discoverable": { + "name": "discoverable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_devices_guestId_unique": { + "name": "browser_devices_guestId_unique", + "columns": [ + "guestId" + ], + "isUnique": true + }, + "browser_devices_code_unique": { + "name": "browser_devices_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "browser_devices_guestId_guest_sessions_id_fk": { + "name": "browser_devices_guestId_guest_sessions_id_fk", + "tableFrom": "browser_devices", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "device_name_length": { + "name": "device_name_length", + "value": "length(\"browser_devices\".\"name\") between 1 and 40" + } + } + }, + "files": { + "name": "files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerGuestId": { + "name": "ownerGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownerUserId": { + "name": "ownerUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storageKey": { + "name": "storageKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadId": { + "name": "uploadId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadKey": { + "name": "uploadKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "files_storageKey_unique": { + "name": "files_storageKey_unique", + "columns": [ + "storageKey" + ], + "isUnique": true + }, + "request_file_position": { + "name": "request_file_position", + "columns": [ + "requestId", + "position" + ], + "isUnique": true + }, + "files_owner_state": { + "name": "files_owner_state", + "columns": [ + "ownerGuestId", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "files_requestId_transfer_requests_id_fk": { + "name": "files_requestId_transfer_requests_id_fk", + "tableFrom": "files", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_ownerGuestId_guest_sessions_id_fk": { + "name": "files_ownerGuestId_guest_sessions_id_fk", + "tableFrom": "files", + "tableTo": "guest_sessions", + "columnsFrom": [ + "ownerGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_ownerUserId_users_id_fk": { + "name": "files_ownerUserId_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "ownerUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "file_nonnegative_size": { + "name": "file_nonnegative_size", + "value": "\"files\".\"size\" >= 0" + }, + "file_owner_subject": { + "name": "file_owner_subject", + "value": "(\"files\".\"ownerGuestId\" IS NOT NULL) + (\"files\".\"ownerUserId\" IS NOT NULL) = 1" + } + } + }, + "guest_sessions": { + "name": "guest_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_sessions_tokenHash_unique": { + "name": "guest_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "guest_expiry": { + "name": "guest_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idempotency_records": { + "name": "idempotency_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payloadHash": { + "name": "payloadHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idempotency_scope": { + "name": "idempotency_scope", + "columns": [ + "guestId", + "operation", + "key" + ], + "isUnique": true + }, + "account_idempotency_scope": { + "name": "account_idempotency_scope", + "columns": [ + "userId", + "operation", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "idempotency_records_userId_users_id_fk": { + "name": "idempotency_records_userId_users_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "idempotency_records_guestId_guest_sessions_id_fk": { + "name": "idempotency_records_guestId_guest_sessions_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "idempotency_subject": { + "name": "idempotency_subject", + "value": "(\"idempotency_records\".\"guestId\" IS NOT NULL) + (\"idempotency_records\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "batchId": { + "name": "batchId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "items_expiry": { + "name": "items_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "items_batchId_transfer_batches_id_fk": { + "name": "items_batchId_transfer_batches_id_fk", + "tableFrom": "items", + "tableTo": "transfer_batches", + "columnsFrom": [ + "batchId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "items_fileId_files_id_fk": { + "name": "items_fileId_files_id_fk", + "tableFrom": "items", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "item_payload": { + "name": "item_payload", + "value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'file' AND \"items\".\"fileId\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"url\" IS NULL)" + } + } + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joinedAt": { + "name": "joinedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "space_device": { + "name": "space_device", + "columns": [ + "spaceId", + "deviceId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "space_members_spaceId_connection_spaces_id_fk": { + "name": "space_members_spaceId_connection_spaces_id_fk", + "tableFrom": "space_members", + "tableTo": "connection_spaces", + "columnsFrom": [ + "spaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "space_members_deviceId_browser_devices_id_fk": { + "name": "space_members_deviceId_browser_devices_id_fk", + "tableFrom": "space_members", + "tableTo": "browser_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_requests": { + "name": "transfer_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "senderGuestId": { + "name": "senderGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientGuestId": { + "name": "recipientGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "senderUserId": { + "name": "senderUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientUserId": { + "name": "recipientUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byteSize": { + "name": "byteSize", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejectedAt": { + "name": "rejectedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startBy": { + "name": "startBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "requests_sender_time": { + "name": "requests_sender_time", + "columns": [ + "senderGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_time": { + "name": "requests_recipient_time", + "columns": [ + "recipientGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_expiry": { + "name": "requests_expiry", + "columns": [ + "status", + "expiresAt" + ], + "isUnique": false + }, + "requests_sender_user": { + "name": "requests_sender_user", + "columns": [ + "senderUserId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_user": { + "name": "requests_recipient_user", + "columns": [ + "recipientUserId", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_requests_senderGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_senderGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "senderGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_recipientGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "recipientGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_senderUserId_users_id_fk": { + "name": "transfer_requests_senderUserId_users_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "users", + "columnsFrom": [ + "senderUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientUserId_users_id_fk": { + "name": "transfer_requests_recipientUserId_users_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "users", + "columnsFrom": [ + "recipientUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "request_different_guests": { + "name": "request_different_guests", + "value": "\"transfer_requests\".\"mode\" <> 'local' OR \"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\"" + }, + "request_sender_subject": { + "name": "request_sender_subject", + "value": "(\"transfer_requests\".\"senderGuestId\" IS NOT NULL) + (\"transfer_requests\".\"senderUserId\" IS NOT NULL) = 1" + }, + "request_recipient_subject": { + "name": "request_recipient_subject", + "value": "(\"transfer_requests\".\"recipientGuestId\" IS NOT NULL) + (\"transfer_requests\".\"recipientUserId\" IS NOT NULL) = 1" + }, + "request_mode_subjects": { + "name": "request_mode_subjects", + "value": "(\"transfer_requests\".\"mode\" = 'local' AND \"transfer_requests\".\"senderGuestId\" IS NOT NULL AND \"transfer_requests\".\"recipientGuestId\" IS NOT NULL) OR (\"transfer_requests\".\"mode\" = 'email' AND \"transfer_requests\".\"senderUserId\" IS NOT NULL AND \"transfer_requests\".\"recipientUserId\" IS NOT NULL)" + } + } + }, + "quota_reservations": { + "name": "quota_reservations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "quota_reservations_fileId_unique": { + "name": "quota_reservations_fileId_unique", + "columns": [ + "fileId" + ], + "isUnique": true + }, + "quota_guest_expiry": { + "name": "quota_guest_expiry", + "columns": [ + "guestId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "quota_reservations_fileId_files_id_fk": { + "name": "quota_reservations_fileId_files_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quota_reservations_userId_users_id_fk": { + "name": "quota_reservations_userId_users_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quota_reservations_guestId_guest_sessions_id_fk": { + "name": "quota_reservations_guestId_guest_sessions_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "reservations_subject": { + "name": "reservations_subject", + "value": "(\"quota_reservations\".\"guestId\" IS NOT NULL) + (\"quota_reservations\".\"userId\" IS NOT NULL) = 1" + }, + "reservation_nonnegative": { + "name": "reservation_nonnegative", + "value": "\"quota_reservations\".\"bytes\" >= 0" + } + } + }, + "user_rules": { + "name": "user_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetUserId": { + "name": "targetUserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_rule_pair": { + "name": "user_rule_pair", + "columns": [ + "userId", + "targetUserId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_rules_userId_users_id_fk": { + "name": "user_rules_userId_users_id_fk", + "tableFrom": "user_rules", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_rules_targetUserId_users_id_fk": { + "name": "user_rules_targetUserId_users_id_fk", + "tableFrom": "user_rules", + "tableTo": "users", + "columnsFrom": [ + "targetUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "rule_not_self": { + "name": "rule_not_self", + "value": "\"user_rules\".\"userId\" <> \"user_rules\".\"targetUserId\"" + } + } + }, + "connection_spaces": { + "name": "connection_spaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "creatorGuestId": { + "name": "creatorGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approvalRequired": { + "name": "approvalRequired", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closedAt": { + "name": "closedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_spaces_tokenHash_unique": { + "name": "connection_spaces_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "connection_spaces_codeHash_unique": { + "name": "connection_spaces_codeHash_unique", + "columns": [ + "codeHash" + ], + "isUnique": true + }, + "space_expiry": { + "name": "space_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "connection_spaces_creatorGuestId_guest_sessions_id_fk": { + "name": "connection_spaces_creatorGuestId_guest_sessions_id_fk", + "tableFrom": "connection_spaces", + "tableTo": "guest_sessions", + "columnsFrom": [ + "creatorGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verifiedAt": { + "name": "verifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "receivePolicy": { + "name": "receivePolicy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requests'" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/server/drizzle/meta/0005_snapshot.json b/apps/server/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..fbe63b0 --- /dev/null +++ b/apps/server/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1853 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "75a32e57-b382-4bf1-b2f3-d8ce61cc420c", + "prevId": "9c096d91-489e-45c4-9a23-48176623283d", + "tables": { + "item_access": { + "name": "item_access", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "itemId": { + "name": "itemId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favorite": { + "name": "favorite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "access_subject_role": { + "name": "access_subject_role", + "columns": [ + "itemId", + "guestId", + "role" + ], + "isUnique": true + }, + "access_account_role": { + "name": "access_account_role", + "columns": [ + "itemId", + "userId", + "role" + ], + "isUnique": true + }, + "access_account": { + "name": "access_account", + "columns": [ + "userId", + "deletedAt" + ], + "isUnique": false + }, + "access_guest": { + "name": "access_guest", + "columns": [ + "guestId", + "deletedAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "item_access_itemId_items_id_fk": { + "name": "item_access_itemId_items_id_fk", + "tableFrom": "item_access", + "tableTo": "items", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_userId_users_id_fk": { + "name": "item_access_userId_users_id_fk", + "tableFrom": "item_access", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_guestId_guest_sessions_id_fk": { + "name": "item_access_guestId_guest_sessions_id_fk", + "tableFrom": "item_access", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "access_subject": { + "name": "access_subject", + "value": "(\"item_access\".\"guestId\" IS NOT NULL) + (\"item_access\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "account_sessions": { + "name": "account_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_sessions_tokenHash_unique": { + "name": "account_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_session_user": { + "name": "account_session_user", + "columns": [ + "userId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_sessions_userId_users_id_fk": { + "name": "account_sessions_userId_users_id_fk", + "tableFrom": "account_sessions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account_tokens": { + "name": "account_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_tokens_tokenHash_unique": { + "name": "account_tokens_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_token_expiry": { + "name": "account_token_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_tokens_userId_users_id_fk": { + "name": "account_tokens_userId_users_id_fk", + "tableFrom": "account_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_batches": { + "name": "transfer_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storedAt": { + "name": "storedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "transfer_batches_requestId_unique": { + "name": "transfer_batches_requestId_unique", + "columns": [ + "requestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "transfer_batches_requestId_transfer_requests_id_fk": { + "name": "transfer_batches_requestId_transfer_requests_id_fk", + "tableFrom": "transfer_batches", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guest_blocks": { + "name": "guest_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetGuestId": { + "name": "targetGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_block_pair": { + "name": "guest_block_pair", + "columns": [ + "guestId", + "targetGuestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guest_blocks_guestId_guest_sessions_id_fk": { + "name": "guest_blocks_guestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "guest_blocks_targetGuestId_guest_sessions_id_fk": { + "name": "guest_blocks_targetGuestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "targetGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_targets": { + "name": "deletion_targets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operationId": { + "name": "operationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessId": { + "name": "accessId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_targets_operationId_deletion_operations_id_fk": { + "name": "deletion_targets_operationId_deletion_operations_id_fk", + "tableFrom": "deletion_targets", + "tableTo": "deletion_operations", + "columnsFrom": [ + "operationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_operations": { + "name": "deletion_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undoUntil": { + "name": "undoUntil", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "restoredAt": { + "name": "restoredAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_operations_userId_users_id_fk": { + "name": "deletion_operations_userId_users_id_fk", + "tableFrom": "deletion_operations", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deletion_operations_guestId_guest_sessions_id_fk": { + "name": "deletion_operations_guestId_guest_sessions_id_fk", + "tableFrom": "deletion_operations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "deletion_subject": { + "name": "deletion_subject", + "value": "(\"deletion_operations\".\"guestId\" IS NOT NULL) + (\"deletion_operations\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "browser_devices": { + "name": "browser_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discoverable": { + "name": "discoverable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_devices_guestId_unique": { + "name": "browser_devices_guestId_unique", + "columns": [ + "guestId" + ], + "isUnique": true + }, + "browser_devices_code_unique": { + "name": "browser_devices_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "browser_devices_guestId_guest_sessions_id_fk": { + "name": "browser_devices_guestId_guest_sessions_id_fk", + "tableFrom": "browser_devices", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "device_name_length": { + "name": "device_name_length", + "value": "length(\"browser_devices\".\"name\") between 1 and 40" + } + } + }, + "files": { + "name": "files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerGuestId": { + "name": "ownerGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownerUserId": { + "name": "ownerUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storageKey": { + "name": "storageKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadId": { + "name": "uploadId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadKey": { + "name": "uploadKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "files_storageKey_unique": { + "name": "files_storageKey_unique", + "columns": [ + "storageKey" + ], + "isUnique": true + }, + "request_file_position": { + "name": "request_file_position", + "columns": [ + "requestId", + "position" + ], + "isUnique": true + }, + "files_owner_state": { + "name": "files_owner_state", + "columns": [ + "ownerGuestId", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "files_requestId_transfer_requests_id_fk": { + "name": "files_requestId_transfer_requests_id_fk", + "tableFrom": "files", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_ownerGuestId_guest_sessions_id_fk": { + "name": "files_ownerGuestId_guest_sessions_id_fk", + "tableFrom": "files", + "tableTo": "guest_sessions", + "columnsFrom": [ + "ownerGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_ownerUserId_users_id_fk": { + "name": "files_ownerUserId_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "ownerUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "file_nonnegative_size": { + "name": "file_nonnegative_size", + "value": "\"files\".\"size\" >= 0" + }, + "file_owner_subject": { + "name": "file_owner_subject", + "value": "(\"files\".\"ownerGuestId\" IS NOT NULL) + (\"files\".\"ownerUserId\" IS NOT NULL) = 1" + } + } + }, + "guest_sessions": { + "name": "guest_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_sessions_tokenHash_unique": { + "name": "guest_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "guest_expiry": { + "name": "guest_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idempotency_records": { + "name": "idempotency_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payloadHash": { + "name": "payloadHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idempotency_scope": { + "name": "idempotency_scope", + "columns": [ + "guestId", + "operation", + "key" + ], + "isUnique": true + }, + "account_idempotency_scope": { + "name": "account_idempotency_scope", + "columns": [ + "userId", + "operation", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "idempotency_records_userId_users_id_fk": { + "name": "idempotency_records_userId_users_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "idempotency_records_guestId_guest_sessions_id_fk": { + "name": "idempotency_records_guestId_guest_sessions_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "idempotency_subject": { + "name": "idempotency_subject", + "value": "(\"idempotency_records\".\"guestId\" IS NOT NULL) + (\"idempotency_records\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "batchId": { + "name": "batchId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "items_expiry": { + "name": "items_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "items_batchId_transfer_batches_id_fk": { + "name": "items_batchId_transfer_batches_id_fk", + "tableFrom": "items", + "tableTo": "transfer_batches", + "columnsFrom": [ + "batchId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "items_fileId_files_id_fk": { + "name": "items_fileId_files_id_fk", + "tableFrom": "items", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "item_payload": { + "name": "item_payload", + "value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'file' AND \"items\".\"fileId\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"url\" IS NULL)" + } + } + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joinedAt": { + "name": "joinedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "space_device": { + "name": "space_device", + "columns": [ + "spaceId", + "deviceId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "space_members_spaceId_connection_spaces_id_fk": { + "name": "space_members_spaceId_connection_spaces_id_fk", + "tableFrom": "space_members", + "tableTo": "connection_spaces", + "columnsFrom": [ + "spaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "space_members_deviceId_browser_devices_id_fk": { + "name": "space_members_deviceId_browser_devices_id_fk", + "tableFrom": "space_members", + "tableTo": "browser_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_requests": { + "name": "transfer_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "senderGuestId": { + "name": "senderGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientGuestId": { + "name": "recipientGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "senderUserId": { + "name": "senderUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientUserId": { + "name": "recipientUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byteSize": { + "name": "byteSize", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejectedAt": { + "name": "rejectedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startBy": { + "name": "startBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "requests_sender_time": { + "name": "requests_sender_time", + "columns": [ + "senderGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_time": { + "name": "requests_recipient_time", + "columns": [ + "recipientGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_expiry": { + "name": "requests_expiry", + "columns": [ + "status", + "expiresAt" + ], + "isUnique": false + }, + "requests_sender_user": { + "name": "requests_sender_user", + "columns": [ + "senderUserId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_user": { + "name": "requests_recipient_user", + "columns": [ + "recipientUserId", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_requests_senderGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_senderGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "senderGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_recipientGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "recipientGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_senderUserId_users_id_fk": { + "name": "transfer_requests_senderUserId_users_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "users", + "columnsFrom": [ + "senderUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientUserId_users_id_fk": { + "name": "transfer_requests_recipientUserId_users_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "users", + "columnsFrom": [ + "recipientUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "request_different_guests": { + "name": "request_different_guests", + "value": "\"transfer_requests\".\"mode\" <> 'local' OR \"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\"" + }, + "request_sender_subject": { + "name": "request_sender_subject", + "value": "(\"transfer_requests\".\"senderGuestId\" IS NOT NULL) + (\"transfer_requests\".\"senderUserId\" IS NOT NULL) = 1" + }, + "request_recipient_subject": { + "name": "request_recipient_subject", + "value": "(\"transfer_requests\".\"recipientGuestId\" IS NOT NULL) + (\"transfer_requests\".\"recipientUserId\" IS NOT NULL) = 1" + }, + "request_mode_subjects": { + "name": "request_mode_subjects", + "value": "(\"transfer_requests\".\"mode\" = 'local' AND \"transfer_requests\".\"senderGuestId\" IS NOT NULL AND \"transfer_requests\".\"recipientGuestId\" IS NOT NULL) OR (\"transfer_requests\".\"mode\" = 'email' AND \"transfer_requests\".\"senderUserId\" IS NOT NULL AND \"transfer_requests\".\"recipientUserId\" IS NOT NULL)" + } + } + }, + "quota_reservations": { + "name": "quota_reservations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "quota_reservations_fileId_unique": { + "name": "quota_reservations_fileId_unique", + "columns": [ + "fileId" + ], + "isUnique": true + }, + "quota_guest_expiry": { + "name": "quota_guest_expiry", + "columns": [ + "guestId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "quota_reservations_fileId_files_id_fk": { + "name": "quota_reservations_fileId_files_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quota_reservations_userId_users_id_fk": { + "name": "quota_reservations_userId_users_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quota_reservations_guestId_guest_sessions_id_fk": { + "name": "quota_reservations_guestId_guest_sessions_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "reservations_subject": { + "name": "reservations_subject", + "value": "(\"quota_reservations\".\"guestId\" IS NOT NULL) + (\"quota_reservations\".\"userId\" IS NOT NULL) = 1" + }, + "reservation_nonnegative": { + "name": "reservation_nonnegative", + "value": "\"quota_reservations\".\"bytes\" >= 0" + } + } + }, + "user_rules": { + "name": "user_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetUserId": { + "name": "targetUserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_rule_pair": { + "name": "user_rule_pair", + "columns": [ + "userId", + "targetUserId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_rules_userId_users_id_fk": { + "name": "user_rules_userId_users_id_fk", + "tableFrom": "user_rules", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_rules_targetUserId_users_id_fk": { + "name": "user_rules_targetUserId_users_id_fk", + "tableFrom": "user_rules", + "tableTo": "users", + "columnsFrom": [ + "targetUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "rule_not_self": { + "name": "rule_not_self", + "value": "\"user_rules\".\"userId\" <> \"user_rules\".\"targetUserId\"" + } + } + }, + "connection_spaces": { + "name": "connection_spaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "creatorGuestId": { + "name": "creatorGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approvalRequired": { + "name": "approvalRequired", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closedAt": { + "name": "closedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_spaces_tokenHash_unique": { + "name": "connection_spaces_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "connection_spaces_codeHash_unique": { + "name": "connection_spaces_codeHash_unique", + "columns": [ + "codeHash" + ], + "isUnique": true + }, + "space_expiry": { + "name": "space_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "connection_spaces_creatorGuestId_guest_sessions_id_fk": { + "name": "connection_spaces_creatorGuestId_guest_sessions_id_fk", + "tableFrom": "connection_spaces", + "tableTo": "guest_sessions", + "columnsFrom": [ + "creatorGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verifiedAt": { + "name": "verifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "receivePolicy": { + "name": "receivePolicy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requests'" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/0006_snapshot.json b/apps/server/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..3befa52 --- /dev/null +++ b/apps/server/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1853 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "45e0c83f-2dd7-40f4-96dd-c70f063854b5", + "prevId": "75a32e57-b382-4bf1-b2f3-d8ce61cc420c", + "tables": { + "item_access": { + "name": "item_access", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "itemId": { + "name": "itemId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favorite": { + "name": "favorite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "access_subject_role": { + "name": "access_subject_role", + "columns": [ + "itemId", + "guestId", + "role" + ], + "isUnique": true + }, + "access_account_role": { + "name": "access_account_role", + "columns": [ + "itemId", + "userId", + "role" + ], + "isUnique": true + }, + "access_account": { + "name": "access_account", + "columns": [ + "userId", + "deletedAt" + ], + "isUnique": false + }, + "access_guest": { + "name": "access_guest", + "columns": [ + "guestId", + "deletedAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "item_access_itemId_items_id_fk": { + "name": "item_access_itemId_items_id_fk", + "tableFrom": "item_access", + "tableTo": "items", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_userId_users_id_fk": { + "name": "item_access_userId_users_id_fk", + "tableFrom": "item_access", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_guestId_guest_sessions_id_fk": { + "name": "item_access_guestId_guest_sessions_id_fk", + "tableFrom": "item_access", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "access_subject": { + "name": "access_subject", + "value": "(\"item_access\".\"guestId\" IS NOT NULL) + (\"item_access\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "account_sessions": { + "name": "account_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_sessions_tokenHash_unique": { + "name": "account_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_session_user": { + "name": "account_session_user", + "columns": [ + "userId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_sessions_userId_users_id_fk": { + "name": "account_sessions_userId_users_id_fk", + "tableFrom": "account_sessions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account_tokens": { + "name": "account_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_tokens_tokenHash_unique": { + "name": "account_tokens_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_token_expiry": { + "name": "account_token_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_tokens_userId_users_id_fk": { + "name": "account_tokens_userId_users_id_fk", + "tableFrom": "account_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_batches": { + "name": "transfer_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storedAt": { + "name": "storedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "transfer_batches_requestId_unique": { + "name": "transfer_batches_requestId_unique", + "columns": [ + "requestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "transfer_batches_requestId_transfer_requests_id_fk": { + "name": "transfer_batches_requestId_transfer_requests_id_fk", + "tableFrom": "transfer_batches", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guest_blocks": { + "name": "guest_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetGuestId": { + "name": "targetGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_block_pair": { + "name": "guest_block_pair", + "columns": [ + "guestId", + "targetGuestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guest_blocks_guestId_guest_sessions_id_fk": { + "name": "guest_blocks_guestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "guest_blocks_targetGuestId_guest_sessions_id_fk": { + "name": "guest_blocks_targetGuestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "targetGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_targets": { + "name": "deletion_targets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operationId": { + "name": "operationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessId": { + "name": "accessId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_targets_operationId_deletion_operations_id_fk": { + "name": "deletion_targets_operationId_deletion_operations_id_fk", + "tableFrom": "deletion_targets", + "tableTo": "deletion_operations", + "columnsFrom": [ + "operationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_operations": { + "name": "deletion_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undoUntil": { + "name": "undoUntil", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "restoredAt": { + "name": "restoredAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_operations_userId_users_id_fk": { + "name": "deletion_operations_userId_users_id_fk", + "tableFrom": "deletion_operations", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deletion_operations_guestId_guest_sessions_id_fk": { + "name": "deletion_operations_guestId_guest_sessions_id_fk", + "tableFrom": "deletion_operations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "deletion_subject": { + "name": "deletion_subject", + "value": "(\"deletion_operations\".\"guestId\" IS NOT NULL) + (\"deletion_operations\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "browser_devices": { + "name": "browser_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discoverable": { + "name": "discoverable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_devices_guestId_unique": { + "name": "browser_devices_guestId_unique", + "columns": [ + "guestId" + ], + "isUnique": true + }, + "browser_devices_code_unique": { + "name": "browser_devices_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "browser_devices_guestId_guest_sessions_id_fk": { + "name": "browser_devices_guestId_guest_sessions_id_fk", + "tableFrom": "browser_devices", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "device_name_length": { + "name": "device_name_length", + "value": "length(\"browser_devices\".\"name\") between 1 and 40" + } + } + }, + "files": { + "name": "files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerGuestId": { + "name": "ownerGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownerUserId": { + "name": "ownerUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storageKey": { + "name": "storageKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadId": { + "name": "uploadId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadKey": { + "name": "uploadKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "files_storageKey_unique": { + "name": "files_storageKey_unique", + "columns": [ + "storageKey" + ], + "isUnique": true + }, + "request_file_position": { + "name": "request_file_position", + "columns": [ + "requestId", + "position" + ], + "isUnique": true + }, + "files_owner_state": { + "name": "files_owner_state", + "columns": [ + "ownerGuestId", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "files_requestId_transfer_requests_id_fk": { + "name": "files_requestId_transfer_requests_id_fk", + "tableFrom": "files", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_ownerGuestId_guest_sessions_id_fk": { + "name": "files_ownerGuestId_guest_sessions_id_fk", + "tableFrom": "files", + "tableTo": "guest_sessions", + "columnsFrom": [ + "ownerGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_ownerUserId_users_id_fk": { + "name": "files_ownerUserId_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "ownerUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "file_nonnegative_size": { + "name": "file_nonnegative_size", + "value": "\"files\".\"size\" >= 0" + }, + "file_owner_subject": { + "name": "file_owner_subject", + "value": "(\"files\".\"ownerGuestId\" IS NOT NULL) + (\"files\".\"ownerUserId\" IS NOT NULL) = 1" + } + } + }, + "guest_sessions": { + "name": "guest_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_sessions_tokenHash_unique": { + "name": "guest_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "guest_expiry": { + "name": "guest_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idempotency_records": { + "name": "idempotency_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payloadHash": { + "name": "payloadHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idempotency_scope": { + "name": "idempotency_scope", + "columns": [ + "guestId", + "operation", + "key" + ], + "isUnique": true + }, + "account_idempotency_scope": { + "name": "account_idempotency_scope", + "columns": [ + "userId", + "operation", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "idempotency_records_userId_users_id_fk": { + "name": "idempotency_records_userId_users_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "idempotency_records_guestId_guest_sessions_id_fk": { + "name": "idempotency_records_guestId_guest_sessions_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "idempotency_subject": { + "name": "idempotency_subject", + "value": "(\"idempotency_records\".\"guestId\" IS NOT NULL) + (\"idempotency_records\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "batchId": { + "name": "batchId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "items_expiry": { + "name": "items_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "items_batchId_transfer_batches_id_fk": { + "name": "items_batchId_transfer_batches_id_fk", + "tableFrom": "items", + "tableTo": "transfer_batches", + "columnsFrom": [ + "batchId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "items_fileId_files_id_fk": { + "name": "items_fileId_files_id_fk", + "tableFrom": "items", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "item_payload": { + "name": "item_payload", + "value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'file' AND \"items\".\"fileId\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"url\" IS NULL)" + } + } + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joinedAt": { + "name": "joinedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "space_device": { + "name": "space_device", + "columns": [ + "spaceId", + "deviceId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "space_members_spaceId_connection_spaces_id_fk": { + "name": "space_members_spaceId_connection_spaces_id_fk", + "tableFrom": "space_members", + "tableTo": "connection_spaces", + "columnsFrom": [ + "spaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "space_members_deviceId_browser_devices_id_fk": { + "name": "space_members_deviceId_browser_devices_id_fk", + "tableFrom": "space_members", + "tableTo": "browser_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_requests": { + "name": "transfer_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "senderGuestId": { + "name": "senderGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientGuestId": { + "name": "recipientGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "senderUserId": { + "name": "senderUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientUserId": { + "name": "recipientUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byteSize": { + "name": "byteSize", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejectedAt": { + "name": "rejectedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startBy": { + "name": "startBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "requests_sender_time": { + "name": "requests_sender_time", + "columns": [ + "senderGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_time": { + "name": "requests_recipient_time", + "columns": [ + "recipientGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_expiry": { + "name": "requests_expiry", + "columns": [ + "status", + "expiresAt" + ], + "isUnique": false + }, + "requests_sender_user": { + "name": "requests_sender_user", + "columns": [ + "senderUserId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_user": { + "name": "requests_recipient_user", + "columns": [ + "recipientUserId", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_requests_senderGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_senderGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "senderGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_recipientGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "recipientGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_senderUserId_users_id_fk": { + "name": "transfer_requests_senderUserId_users_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "users", + "columnsFrom": [ + "senderUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientUserId_users_id_fk": { + "name": "transfer_requests_recipientUserId_users_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "users", + "columnsFrom": [ + "recipientUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "request_different_guests": { + "name": "request_different_guests", + "value": "\"transfer_requests\".\"mode\" <> 'local' OR \"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\"" + }, + "request_sender_subject": { + "name": "request_sender_subject", + "value": "(\"transfer_requests\".\"senderGuestId\" IS NOT NULL) + (\"transfer_requests\".\"senderUserId\" IS NOT NULL) = 1" + }, + "request_recipient_subject": { + "name": "request_recipient_subject", + "value": "(\"transfer_requests\".\"recipientGuestId\" IS NOT NULL) + (\"transfer_requests\".\"recipientUserId\" IS NOT NULL) = 1" + }, + "request_mode_subjects": { + "name": "request_mode_subjects", + "value": "(\"transfer_requests\".\"mode\" = 'local' AND \"transfer_requests\".\"senderGuestId\" IS NOT NULL AND \"transfer_requests\".\"recipientGuestId\" IS NOT NULL) OR (\"transfer_requests\".\"mode\" = 'email' AND \"transfer_requests\".\"senderUserId\" IS NOT NULL AND \"transfer_requests\".\"recipientUserId\" IS NOT NULL)" + } + } + }, + "quota_reservations": { + "name": "quota_reservations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "quota_reservations_fileId_unique": { + "name": "quota_reservations_fileId_unique", + "columns": [ + "fileId" + ], + "isUnique": true + }, + "quota_guest_expiry": { + "name": "quota_guest_expiry", + "columns": [ + "guestId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "quota_reservations_fileId_files_id_fk": { + "name": "quota_reservations_fileId_files_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quota_reservations_userId_users_id_fk": { + "name": "quota_reservations_userId_users_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quota_reservations_guestId_guest_sessions_id_fk": { + "name": "quota_reservations_guestId_guest_sessions_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "reservations_subject": { + "name": "reservations_subject", + "value": "(\"quota_reservations\".\"guestId\" IS NOT NULL) + (\"quota_reservations\".\"userId\" IS NOT NULL) = 1" + }, + "reservation_nonnegative": { + "name": "reservation_nonnegative", + "value": "\"quota_reservations\".\"bytes\" >= 0" + } + } + }, + "user_rules": { + "name": "user_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetUserId": { + "name": "targetUserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_rule_pair": { + "name": "user_rule_pair", + "columns": [ + "userId", + "targetUserId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_rules_userId_users_id_fk": { + "name": "user_rules_userId_users_id_fk", + "tableFrom": "user_rules", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_rules_targetUserId_users_id_fk": { + "name": "user_rules_targetUserId_users_id_fk", + "tableFrom": "user_rules", + "tableTo": "users", + "columnsFrom": [ + "targetUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "rule_not_self": { + "name": "rule_not_self", + "value": "\"user_rules\".\"userId\" <> \"user_rules\".\"targetUserId\"" + } + } + }, + "connection_spaces": { + "name": "connection_spaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "creatorGuestId": { + "name": "creatorGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approvalRequired": { + "name": "approvalRequired", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closedAt": { + "name": "closedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_spaces_tokenHash_unique": { + "name": "connection_spaces_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "connection_spaces_codeHash_unique": { + "name": "connection_spaces_codeHash_unique", + "columns": [ + "codeHash" + ], + "isUnique": true + }, + "space_expiry": { + "name": "space_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "connection_spaces_creatorGuestId_guest_sessions_id_fk": { + "name": "connection_spaces_creatorGuestId_guest_sessions_id_fk", + "tableFrom": "connection_spaces", + "tableTo": "guest_sessions", + "columnsFrom": [ + "creatorGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verifiedAt": { + "name": "verifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "receivePolicy": { + "name": "receivePolicy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requests'" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/server/drizzle/meta/0007_snapshot.json b/apps/server/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..9353cfc --- /dev/null +++ b/apps/server/drizzle/meta/0007_snapshot.json @@ -0,0 +1,2022 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e464a2c2-8de8-4d97-a3d5-abdf9076fd4b", + "prevId": "45e0c83f-2dd7-40f4-96dd-c70f063854b5", + "tables": { + "item_access": { + "name": "item_access", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "itemId": { + "name": "itemId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favorite": { + "name": "favorite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "access_subject_role": { + "name": "access_subject_role", + "columns": [ + "itemId", + "guestId", + "role" + ], + "isUnique": true + }, + "access_account_role": { + "name": "access_account_role", + "columns": [ + "itemId", + "userId", + "role" + ], + "isUnique": true + }, + "access_account": { + "name": "access_account", + "columns": [ + "userId", + "deletedAt" + ], + "isUnique": false + }, + "access_guest": { + "name": "access_guest", + "columns": [ + "guestId", + "deletedAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "item_access_itemId_items_id_fk": { + "name": "item_access_itemId_items_id_fk", + "tableFrom": "item_access", + "tableTo": "items", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_userId_users_id_fk": { + "name": "item_access_userId_users_id_fk", + "tableFrom": "item_access", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_guestId_guest_sessions_id_fk": { + "name": "item_access_guestId_guest_sessions_id_fk", + "tableFrom": "item_access", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "access_subject": { + "name": "access_subject", + "value": "(\"item_access\".\"guestId\" IS NOT NULL) + (\"item_access\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "account_sessions": { + "name": "account_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_sessions_tokenHash_unique": { + "name": "account_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_session_user": { + "name": "account_session_user", + "columns": [ + "userId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_sessions_userId_users_id_fk": { + "name": "account_sessions_userId_users_id_fk", + "tableFrom": "account_sessions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account_tokens": { + "name": "account_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_tokens_tokenHash_unique": { + "name": "account_tokens_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_token_expiry": { + "name": "account_token_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_tokens_userId_users_id_fk": { + "name": "account_tokens_userId_users_id_fk", + "tableFrom": "account_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_batches": { + "name": "transfer_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storedAt": { + "name": "storedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "transfer_batches_requestId_unique": { + "name": "transfer_batches_requestId_unique", + "columns": [ + "requestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "transfer_batches_requestId_transfer_requests_id_fk": { + "name": "transfer_batches_requestId_transfer_requests_id_fk", + "tableFrom": "transfer_batches", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guest_blocks": { + "name": "guest_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetGuestId": { + "name": "targetGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_block_pair": { + "name": "guest_block_pair", + "columns": [ + "guestId", + "targetGuestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guest_blocks_guestId_guest_sessions_id_fk": { + "name": "guest_blocks_guestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "guest_blocks_targetGuestId_guest_sessions_id_fk": { + "name": "guest_blocks_targetGuestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "targetGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_targets": { + "name": "deletion_targets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operationId": { + "name": "operationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessId": { + "name": "accessId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_targets_operationId_deletion_operations_id_fk": { + "name": "deletion_targets_operationId_deletion_operations_id_fk", + "tableFrom": "deletion_targets", + "tableTo": "deletion_operations", + "columnsFrom": [ + "operationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_operations": { + "name": "deletion_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undoUntil": { + "name": "undoUntil", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "restoredAt": { + "name": "restoredAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_operations_userId_users_id_fk": { + "name": "deletion_operations_userId_users_id_fk", + "tableFrom": "deletion_operations", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deletion_operations_guestId_guest_sessions_id_fk": { + "name": "deletion_operations_guestId_guest_sessions_id_fk", + "tableFrom": "deletion_operations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "deletion_subject": { + "name": "deletion_subject", + "value": "(\"deletion_operations\".\"guestId\" IS NOT NULL) + (\"deletion_operations\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "browser_devices": { + "name": "browser_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discoverable": { + "name": "discoverable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_devices_guestId_unique": { + "name": "browser_devices_guestId_unique", + "columns": [ + "guestId" + ], + "isUnique": true + }, + "browser_devices_code_unique": { + "name": "browser_devices_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "browser_devices_guestId_guest_sessions_id_fk": { + "name": "browser_devices_guestId_guest_sessions_id_fk", + "tableFrom": "browser_devices", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "device_name_length": { + "name": "device_name_length", + "value": "length(\"browser_devices\".\"name\") between 1 and 40" + } + } + }, + "files": { + "name": "files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerGuestId": { + "name": "ownerGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownerUserId": { + "name": "ownerUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storageKey": { + "name": "storageKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadId": { + "name": "uploadId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadKey": { + "name": "uploadKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "files_storageKey_unique": { + "name": "files_storageKey_unique", + "columns": [ + "storageKey" + ], + "isUnique": true + }, + "request_file_position": { + "name": "request_file_position", + "columns": [ + "requestId", + "position" + ], + "isUnique": true + }, + "files_owner_state": { + "name": "files_owner_state", + "columns": [ + "ownerGuestId", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "files_requestId_transfer_requests_id_fk": { + "name": "files_requestId_transfer_requests_id_fk", + "tableFrom": "files", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_ownerGuestId_guest_sessions_id_fk": { + "name": "files_ownerGuestId_guest_sessions_id_fk", + "tableFrom": "files", + "tableTo": "guest_sessions", + "columnsFrom": [ + "ownerGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_ownerUserId_users_id_fk": { + "name": "files_ownerUserId_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "ownerUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "file_nonnegative_size": { + "name": "file_nonnegative_size", + "value": "\"files\".\"size\" >= 0" + }, + "file_owner_subject": { + "name": "file_owner_subject", + "value": "(\"files\".\"ownerGuestId\" IS NOT NULL) + (\"files\".\"ownerUserId\" IS NOT NULL) = 1" + } + } + }, + "guest_sessions": { + "name": "guest_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_sessions_tokenHash_unique": { + "name": "guest_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "guest_expiry": { + "name": "guest_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idempotency_records": { + "name": "idempotency_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payloadHash": { + "name": "payloadHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idempotency_scope": { + "name": "idempotency_scope", + "columns": [ + "guestId", + "operation", + "key" + ], + "isUnique": true + }, + "account_idempotency_scope": { + "name": "account_idempotency_scope", + "columns": [ + "userId", + "operation", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "idempotency_records_userId_users_id_fk": { + "name": "idempotency_records_userId_users_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "idempotency_records_guestId_guest_sessions_id_fk": { + "name": "idempotency_records_guestId_guest_sessions_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "idempotency_subject": { + "name": "idempotency_subject", + "value": "(\"idempotency_records\".\"guestId\" IS NOT NULL) + (\"idempotency_records\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "batchId": { + "name": "batchId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "items_expiry": { + "name": "items_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "items_batchId_transfer_batches_id_fk": { + "name": "items_batchId_transfer_batches_id_fk", + "tableFrom": "items", + "tableTo": "transfer_batches", + "columnsFrom": [ + "batchId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "items_fileId_files_id_fk": { + "name": "items_fileId_files_id_fk", + "tableFrom": "items", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "item_payload": { + "name": "item_payload", + "value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'file' AND \"items\".\"fileId\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"url\" IS NULL)" + } + } + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joinedAt": { + "name": "joinedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "space_device": { + "name": "space_device", + "columns": [ + "spaceId", + "deviceId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "space_members_spaceId_connection_spaces_id_fk": { + "name": "space_members_spaceId_connection_spaces_id_fk", + "tableFrom": "space_members", + "tableTo": "connection_spaces", + "columnsFrom": [ + "spaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "space_members_deviceId_browser_devices_id_fk": { + "name": "space_members_deviceId_browser_devices_id_fk", + "tableFrom": "space_members", + "tableTo": "browser_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "request_receipt_accounts": { + "name": "request_receipt_accounts", + "columns": { + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "request_receipt_accounts_requestId_transfer_requests_id_fk": { + "name": "request_receipt_accounts_requestId_transfer_requests_id_fk", + "tableFrom": "request_receipt_accounts", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "request_receipt_accounts_userId_users_id_fk": { + "name": "request_receipt_accounts_userId_users_id_fk", + "tableFrom": "request_receipt_accounts", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "request_receipt_accounts_sessionId_account_sessions_id_fk": { + "name": "request_receipt_accounts_sessionId_account_sessions_id_fk", + "tableFrom": "request_receipt_accounts", + "tableTo": "account_sessions", + "columnsFrom": [ + "sessionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_requests": { + "name": "transfer_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "senderGuestId": { + "name": "senderGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientGuestId": { + "name": "recipientGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "senderUserId": { + "name": "senderUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientUserId": { + "name": "recipientUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byteSize": { + "name": "byteSize", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejectedAt": { + "name": "rejectedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startBy": { + "name": "startBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "requests_sender_time": { + "name": "requests_sender_time", + "columns": [ + "senderGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_time": { + "name": "requests_recipient_time", + "columns": [ + "recipientGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_expiry": { + "name": "requests_expiry", + "columns": [ + "status", + "expiresAt" + ], + "isUnique": false + }, + "requests_sender_user": { + "name": "requests_sender_user", + "columns": [ + "senderUserId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_user": { + "name": "requests_recipient_user", + "columns": [ + "recipientUserId", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_requests_senderGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_senderGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "senderGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_recipientGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "recipientGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_senderUserId_users_id_fk": { + "name": "transfer_requests_senderUserId_users_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "users", + "columnsFrom": [ + "senderUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientUserId_users_id_fk": { + "name": "transfer_requests_recipientUserId_users_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "users", + "columnsFrom": [ + "recipientUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "request_different_guests": { + "name": "request_different_guests", + "value": "\"transfer_requests\".\"mode\" <> 'local' OR \"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\"" + }, + "request_sender_subject": { + "name": "request_sender_subject", + "value": "(\"transfer_requests\".\"senderGuestId\" IS NOT NULL) + (\"transfer_requests\".\"senderUserId\" IS NOT NULL) = 1" + }, + "request_recipient_subject": { + "name": "request_recipient_subject", + "value": "(\"transfer_requests\".\"recipientGuestId\" IS NOT NULL) + (\"transfer_requests\".\"recipientUserId\" IS NOT NULL) = 1" + }, + "request_mode_subjects": { + "name": "request_mode_subjects", + "value": "(\"transfer_requests\".\"mode\" = 'local' AND \"transfer_requests\".\"senderGuestId\" IS NOT NULL AND \"transfer_requests\".\"recipientGuestId\" IS NOT NULL) OR (\"transfer_requests\".\"mode\" = 'email' AND \"transfer_requests\".\"senderUserId\" IS NOT NULL AND \"transfer_requests\".\"recipientUserId\" IS NOT NULL)" + } + } + }, + "quota_reservations": { + "name": "quota_reservations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "quota_reservations_fileId_unique": { + "name": "quota_reservations_fileId_unique", + "columns": [ + "fileId" + ], + "isUnique": true + }, + "quota_guest_expiry": { + "name": "quota_guest_expiry", + "columns": [ + "guestId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "quota_reservations_fileId_files_id_fk": { + "name": "quota_reservations_fileId_files_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quota_reservations_userId_users_id_fk": { + "name": "quota_reservations_userId_users_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quota_reservations_guestId_guest_sessions_id_fk": { + "name": "quota_reservations_guestId_guest_sessions_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "reservations_subject": { + "name": "reservations_subject", + "value": "(\"quota_reservations\".\"guestId\" IS NOT NULL) + (\"quota_reservations\".\"userId\" IS NOT NULL) = 1" + }, + "reservation_nonnegative": { + "name": "reservation_nonnegative", + "value": "\"quota_reservations\".\"bytes\" >= 0" + } + } + }, + "user_rules": { + "name": "user_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetUserId": { + "name": "targetUserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_rule_pair": { + "name": "user_rule_pair", + "columns": [ + "userId", + "targetUserId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_rules_userId_users_id_fk": { + "name": "user_rules_userId_users_id_fk", + "tableFrom": "user_rules", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_rules_targetUserId_users_id_fk": { + "name": "user_rules_targetUserId_users_id_fk", + "tableFrom": "user_rules", + "tableTo": "users", + "columnsFrom": [ + "targetUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "rule_not_self": { + "name": "rule_not_self", + "value": "\"user_rules\".\"userId\" <> \"user_rules\".\"targetUserId\"" + } + } + }, + "connection_spaces": { + "name": "connection_spaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "creatorGuestId": { + "name": "creatorGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approvalRequired": { + "name": "approvalRequired", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closedAt": { + "name": "closedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_spaces_tokenHash_unique": { + "name": "connection_spaces_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "connection_spaces_codeHash_unique": { + "name": "connection_spaces_codeHash_unique", + "columns": [ + "codeHash" + ], + "isUnique": true + }, + "space_expiry": { + "name": "space_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "connection_spaces_creatorGuestId_guest_sessions_id_fk": { + "name": "connection_spaces_creatorGuestId_guest_sessions_id_fk", + "tableFrom": "connection_spaces", + "tableTo": "guest_sessions", + "columnsFrom": [ + "creatorGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verifiedAt": { + "name": "verifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "receivePolicy": { + "name": "receivePolicy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requests'" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_transfers": { + "name": "vault_transfers", + "columns": { + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "accessId": { + "name": "accessId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "vault_transfers_requestId_transfer_requests_id_fk": { + "name": "vault_transfers_requestId_transfer_requests_id_fk", + "tableFrom": "vault_transfers", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "vault_transfers_accessId_item_access_id_fk": { + "name": "vault_transfers_accessId_item_access_id_fk", + "tableFrom": "vault_transfers", + "tableTo": "item_access", + "columnsFrom": [ + "accessId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "vault_transfers_userId_users_id_fk": { + "name": "vault_transfers_userId_users_id_fk", + "tableFrom": "vault_transfers", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "vault_transfers_sessionId_account_sessions_id_fk": { + "name": "vault_transfers_sessionId_account_sessions_id_fk", + "tableFrom": "vault_transfers", + "tableTo": "account_sessions", + "columnsFrom": [ + "sessionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/0008_snapshot.json b/apps/server/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..292c5f5 --- /dev/null +++ b/apps/server/drizzle/meta/0008_snapshot.json @@ -0,0 +1,2022 @@ +{ + "id": "6195aafa-ed43-4ab2-922c-7fb3cc18b892", + "prevId": "e464a2c2-8de8-4d97-a3d5-abdf9076fd4b", + "version": "6", + "dialect": "sqlite", + "tables": { + "item_access": { + "name": "item_access", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "itemId": { + "name": "itemId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favorite": { + "name": "favorite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "access_subject_role": { + "name": "access_subject_role", + "columns": [ + "itemId", + "guestId", + "role" + ], + "isUnique": true + }, + "access_account_role": { + "name": "access_account_role", + "columns": [ + "itemId", + "userId", + "role" + ], + "isUnique": true + }, + "access_account": { + "name": "access_account", + "columns": [ + "userId", + "deletedAt" + ], + "isUnique": false + }, + "access_guest": { + "name": "access_guest", + "columns": [ + "guestId", + "deletedAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "item_access_itemId_items_id_fk": { + "name": "item_access_itemId_items_id_fk", + "tableFrom": "item_access", + "columnsFrom": [ + "itemId" + ], + "tableTo": "items", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "item_access_userId_users_id_fk": { + "name": "item_access_userId_users_id_fk", + "tableFrom": "item_access", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "item_access_guestId_guest_sessions_id_fk": { + "name": "item_access_guestId_guest_sessions_id_fk", + "tableFrom": "item_access", + "columnsFrom": [ + "guestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "access_subject": { + "name": "access_subject", + "value": "(\"item_access\".\"guestId\" IS NOT NULL) + (\"item_access\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "account_sessions": { + "name": "account_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_sessions_tokenHash_unique": { + "name": "account_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_session_user": { + "name": "account_session_user", + "columns": [ + "userId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_sessions_userId_users_id_fk": { + "name": "account_sessions_userId_users_id_fk", + "tableFrom": "account_sessions", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account_tokens": { + "name": "account_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_tokens_tokenHash_unique": { + "name": "account_tokens_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_token_expiry": { + "name": "account_token_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_tokens_userId_users_id_fk": { + "name": "account_tokens_userId_users_id_fk", + "tableFrom": "account_tokens", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_batches": { + "name": "transfer_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storedAt": { + "name": "storedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "transfer_batches_requestId_unique": { + "name": "transfer_batches_requestId_unique", + "columns": [ + "requestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "transfer_batches_requestId_transfer_requests_id_fk": { + "name": "transfer_batches_requestId_transfer_requests_id_fk", + "tableFrom": "transfer_batches", + "columnsFrom": [ + "requestId" + ], + "tableTo": "transfer_requests", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guest_blocks": { + "name": "guest_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetGuestId": { + "name": "targetGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_block_pair": { + "name": "guest_block_pair", + "columns": [ + "guestId", + "targetGuestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guest_blocks_guestId_guest_sessions_id_fk": { + "name": "guest_blocks_guestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "columnsFrom": [ + "guestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "guest_blocks_targetGuestId_guest_sessions_id_fk": { + "name": "guest_blocks_targetGuestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "columnsFrom": [ + "targetGuestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_targets": { + "name": "deletion_targets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operationId": { + "name": "operationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessId": { + "name": "accessId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_targets_operationId_deletion_operations_id_fk": { + "name": "deletion_targets_operationId_deletion_operations_id_fk", + "tableFrom": "deletion_targets", + "columnsFrom": [ + "operationId" + ], + "tableTo": "deletion_operations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_operations": { + "name": "deletion_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undoUntil": { + "name": "undoUntil", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "restoredAt": { + "name": "restoredAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_operations_userId_users_id_fk": { + "name": "deletion_operations_userId_users_id_fk", + "tableFrom": "deletion_operations", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "deletion_operations_guestId_guest_sessions_id_fk": { + "name": "deletion_operations_guestId_guest_sessions_id_fk", + "tableFrom": "deletion_operations", + "columnsFrom": [ + "guestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "deletion_subject": { + "name": "deletion_subject", + "value": "(\"deletion_operations\".\"guestId\" IS NOT NULL) + (\"deletion_operations\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "browser_devices": { + "name": "browser_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discoverable": { + "name": "discoverable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_devices_guestId_unique": { + "name": "browser_devices_guestId_unique", + "columns": [ + "guestId" + ], + "isUnique": true + }, + "browser_devices_code_unique": { + "name": "browser_devices_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "browser_devices_guestId_guest_sessions_id_fk": { + "name": "browser_devices_guestId_guest_sessions_id_fk", + "tableFrom": "browser_devices", + "columnsFrom": [ + "guestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "device_name_length": { + "name": "device_name_length", + "value": "length(\"browser_devices\".\"name\") between 1 and 40" + } + } + }, + "files": { + "name": "files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerGuestId": { + "name": "ownerGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownerUserId": { + "name": "ownerUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storageKey": { + "name": "storageKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadId": { + "name": "uploadId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadKey": { + "name": "uploadKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "files_storageKey_unique": { + "name": "files_storageKey_unique", + "columns": [ + "storageKey" + ], + "isUnique": true + }, + "request_file_position": { + "name": "request_file_position", + "columns": [ + "requestId", + "position" + ], + "isUnique": true + }, + "files_owner_state": { + "name": "files_owner_state", + "columns": [ + "ownerGuestId", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "files_requestId_transfer_requests_id_fk": { + "name": "files_requestId_transfer_requests_id_fk", + "tableFrom": "files", + "columnsFrom": [ + "requestId" + ], + "tableTo": "transfer_requests", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "files_ownerGuestId_guest_sessions_id_fk": { + "name": "files_ownerGuestId_guest_sessions_id_fk", + "tableFrom": "files", + "columnsFrom": [ + "ownerGuestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "files_ownerUserId_users_id_fk": { + "name": "files_ownerUserId_users_id_fk", + "tableFrom": "files", + "columnsFrom": [ + "ownerUserId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "file_nonnegative_size": { + "name": "file_nonnegative_size", + "value": "\"files\".\"size\" >= 0" + }, + "file_owner_subject": { + "name": "file_owner_subject", + "value": "(\"files\".\"ownerGuestId\" IS NOT NULL) + (\"files\".\"ownerUserId\" IS NOT NULL) = 1" + } + } + }, + "guest_sessions": { + "name": "guest_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_sessions_tokenHash_unique": { + "name": "guest_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "guest_expiry": { + "name": "guest_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idempotency_records": { + "name": "idempotency_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payloadHash": { + "name": "payloadHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idempotency_scope": { + "name": "idempotency_scope", + "columns": [ + "guestId", + "operation", + "key" + ], + "isUnique": true + }, + "account_idempotency_scope": { + "name": "account_idempotency_scope", + "columns": [ + "userId", + "operation", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "idempotency_records_userId_users_id_fk": { + "name": "idempotency_records_userId_users_id_fk", + "tableFrom": "idempotency_records", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "idempotency_records_guestId_guest_sessions_id_fk": { + "name": "idempotency_records_guestId_guest_sessions_id_fk", + "tableFrom": "idempotency_records", + "columnsFrom": [ + "guestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "idempotency_subject": { + "name": "idempotency_subject", + "value": "(\"idempotency_records\".\"guestId\" IS NOT NULL) + (\"idempotency_records\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "batchId": { + "name": "batchId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "items_expiry": { + "name": "items_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "items_batchId_transfer_batches_id_fk": { + "name": "items_batchId_transfer_batches_id_fk", + "tableFrom": "items", + "columnsFrom": [ + "batchId" + ], + "tableTo": "transfer_batches", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "items_fileId_files_id_fk": { + "name": "items_fileId_files_id_fk", + "tableFrom": "items", + "columnsFrom": [ + "fileId" + ], + "tableTo": "files", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "item_payload": { + "name": "item_payload", + "value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'file' AND \"items\".\"fileId\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"url\" IS NULL)" + } + } + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joinedAt": { + "name": "joinedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "space_device": { + "name": "space_device", + "columns": [ + "spaceId", + "deviceId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "space_members_spaceId_connection_spaces_id_fk": { + "name": "space_members_spaceId_connection_spaces_id_fk", + "tableFrom": "space_members", + "columnsFrom": [ + "spaceId" + ], + "tableTo": "connection_spaces", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "space_members_deviceId_browser_devices_id_fk": { + "name": "space_members_deviceId_browser_devices_id_fk", + "tableFrom": "space_members", + "columnsFrom": [ + "deviceId" + ], + "tableTo": "browser_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "request_receipt_accounts": { + "name": "request_receipt_accounts", + "columns": { + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "request_receipt_accounts_requestId_transfer_requests_id_fk": { + "name": "request_receipt_accounts_requestId_transfer_requests_id_fk", + "tableFrom": "request_receipt_accounts", + "columnsFrom": [ + "requestId" + ], + "tableTo": "transfer_requests", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "request_receipt_accounts_userId_users_id_fk": { + "name": "request_receipt_accounts_userId_users_id_fk", + "tableFrom": "request_receipt_accounts", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "request_receipt_accounts_sessionId_account_sessions_id_fk": { + "name": "request_receipt_accounts_sessionId_account_sessions_id_fk", + "tableFrom": "request_receipt_accounts", + "columnsFrom": [ + "sessionId" + ], + "tableTo": "account_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_requests": { + "name": "transfer_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "senderGuestId": { + "name": "senderGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientGuestId": { + "name": "recipientGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "senderUserId": { + "name": "senderUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientUserId": { + "name": "recipientUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byteSize": { + "name": "byteSize", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejectedAt": { + "name": "rejectedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startBy": { + "name": "startBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "requests_sender_time": { + "name": "requests_sender_time", + "columns": [ + "senderGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_time": { + "name": "requests_recipient_time", + "columns": [ + "recipientGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_expiry": { + "name": "requests_expiry", + "columns": [ + "status", + "expiresAt" + ], + "isUnique": false + }, + "requests_sender_user": { + "name": "requests_sender_user", + "columns": [ + "senderUserId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_user": { + "name": "requests_recipient_user", + "columns": [ + "recipientUserId", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_requests_senderGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_senderGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "columnsFrom": [ + "senderGuestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "transfer_requests_recipientGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_recipientGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "columnsFrom": [ + "recipientGuestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "transfer_requests_senderUserId_users_id_fk": { + "name": "transfer_requests_senderUserId_users_id_fk", + "tableFrom": "transfer_requests", + "columnsFrom": [ + "senderUserId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "transfer_requests_recipientUserId_users_id_fk": { + "name": "transfer_requests_recipientUserId_users_id_fk", + "tableFrom": "transfer_requests", + "columnsFrom": [ + "recipientUserId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "request_different_guests": { + "name": "request_different_guests", + "value": "\"transfer_requests\".\"mode\" <> 'local' OR \"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\"" + }, + "request_sender_subject": { + "name": "request_sender_subject", + "value": "(\"transfer_requests\".\"senderGuestId\" IS NOT NULL) + (\"transfer_requests\".\"senderUserId\" IS NOT NULL) = 1" + }, + "request_recipient_subject": { + "name": "request_recipient_subject", + "value": "(\"transfer_requests\".\"recipientGuestId\" IS NOT NULL) + (\"transfer_requests\".\"recipientUserId\" IS NOT NULL) = 1" + }, + "request_mode_subjects": { + "name": "request_mode_subjects", + "value": "(\"transfer_requests\".\"mode\" = 'local' AND \"transfer_requests\".\"senderGuestId\" IS NOT NULL AND \"transfer_requests\".\"recipientGuestId\" IS NOT NULL) OR (\"transfer_requests\".\"mode\" = 'email' AND \"transfer_requests\".\"senderUserId\" IS NOT NULL AND \"transfer_requests\".\"recipientUserId\" IS NOT NULL)" + } + } + }, + "quota_reservations": { + "name": "quota_reservations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "quota_reservations_fileId_unique": { + "name": "quota_reservations_fileId_unique", + "columns": [ + "fileId" + ], + "isUnique": true + }, + "quota_guest_expiry": { + "name": "quota_guest_expiry", + "columns": [ + "guestId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "quota_reservations_fileId_files_id_fk": { + "name": "quota_reservations_fileId_files_id_fk", + "tableFrom": "quota_reservations", + "columnsFrom": [ + "fileId" + ], + "tableTo": "files", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "quota_reservations_userId_users_id_fk": { + "name": "quota_reservations_userId_users_id_fk", + "tableFrom": "quota_reservations", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "quota_reservations_guestId_guest_sessions_id_fk": { + "name": "quota_reservations_guestId_guest_sessions_id_fk", + "tableFrom": "quota_reservations", + "columnsFrom": [ + "guestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "reservations_subject": { + "name": "reservations_subject", + "value": "(\"quota_reservations\".\"guestId\" IS NOT NULL) + (\"quota_reservations\".\"userId\" IS NOT NULL) = 1" + }, + "reservation_nonnegative": { + "name": "reservation_nonnegative", + "value": "\"quota_reservations\".\"bytes\" >= 0" + } + } + }, + "user_rules": { + "name": "user_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetUserId": { + "name": "targetUserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_rule_pair": { + "name": "user_rule_pair", + "columns": [ + "userId", + "targetUserId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_rules_userId_users_id_fk": { + "name": "user_rules_userId_users_id_fk", + "tableFrom": "user_rules", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "user_rules_targetUserId_users_id_fk": { + "name": "user_rules_targetUserId_users_id_fk", + "tableFrom": "user_rules", + "columnsFrom": [ + "targetUserId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "rule_not_self": { + "name": "rule_not_self", + "value": "\"user_rules\".\"userId\" <> \"user_rules\".\"targetUserId\"" + } + } + }, + "connection_spaces": { + "name": "connection_spaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "creatorGuestId": { + "name": "creatorGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approvalRequired": { + "name": "approvalRequired", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closedAt": { + "name": "closedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_spaces_tokenHash_unique": { + "name": "connection_spaces_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "connection_spaces_codeHash_unique": { + "name": "connection_spaces_codeHash_unique", + "columns": [ + "codeHash" + ], + "isUnique": true + }, + "space_expiry": { + "name": "space_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "connection_spaces_creatorGuestId_guest_sessions_id_fk": { + "name": "connection_spaces_creatorGuestId_guest_sessions_id_fk", + "tableFrom": "connection_spaces", + "columnsFrom": [ + "creatorGuestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verifiedAt": { + "name": "verifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "receivePolicy": { + "name": "receivePolicy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requests'" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_transfers": { + "name": "vault_transfers", + "columns": { + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "accessId": { + "name": "accessId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "vault_transfers_requestId_transfer_requests_id_fk": { + "name": "vault_transfers_requestId_transfer_requests_id_fk", + "tableFrom": "vault_transfers", + "columnsFrom": [ + "requestId" + ], + "tableTo": "transfer_requests", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "vault_transfers_accessId_item_access_id_fk": { + "name": "vault_transfers_accessId_item_access_id_fk", + "tableFrom": "vault_transfers", + "columnsFrom": [ + "accessId" + ], + "tableTo": "item_access", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "vault_transfers_userId_users_id_fk": { + "name": "vault_transfers_userId_users_id_fk", + "tableFrom": "vault_transfers", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "vault_transfers_sessionId_account_sessions_id_fk": { + "name": "vault_transfers_sessionId_account_sessions_id_fk", + "tableFrom": "vault_transfers", + "columnsFrom": [ + "sessionId" + ], + "tableTo": "account_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/0009_snapshot.json b/apps/server/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..b65fc80 --- /dev/null +++ b/apps/server/drizzle/meta/0009_snapshot.json @@ -0,0 +1,2029 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4d2a3ed2-4aab-4fe7-92c0-b0bd8be524c3", + "prevId": "6195aafa-ed43-4ab2-922c-7fb3cc18b892", + "tables": { + "item_access": { + "name": "item_access", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "itemId": { + "name": "itemId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favorite": { + "name": "favorite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "access_subject_role": { + "name": "access_subject_role", + "columns": [ + "itemId", + "guestId", + "role" + ], + "isUnique": true + }, + "access_account_role": { + "name": "access_account_role", + "columns": [ + "itemId", + "userId", + "role" + ], + "isUnique": true + }, + "access_account": { + "name": "access_account", + "columns": [ + "userId", + "deletedAt" + ], + "isUnique": false + }, + "access_guest": { + "name": "access_guest", + "columns": [ + "guestId", + "deletedAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "item_access_itemId_items_id_fk": { + "name": "item_access_itemId_items_id_fk", + "tableFrom": "item_access", + "tableTo": "items", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_userId_users_id_fk": { + "name": "item_access_userId_users_id_fk", + "tableFrom": "item_access", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_guestId_guest_sessions_id_fk": { + "name": "item_access_guestId_guest_sessions_id_fk", + "tableFrom": "item_access", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "access_subject": { + "name": "access_subject", + "value": "(\"item_access\".\"guestId\" IS NOT NULL) + (\"item_access\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "account_sessions": { + "name": "account_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_sessions_tokenHash_unique": { + "name": "account_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_session_user": { + "name": "account_session_user", + "columns": [ + "userId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_sessions_userId_users_id_fk": { + "name": "account_sessions_userId_users_id_fk", + "tableFrom": "account_sessions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account_tokens": { + "name": "account_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_tokens_tokenHash_unique": { + "name": "account_tokens_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_token_expiry": { + "name": "account_token_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_tokens_userId_users_id_fk": { + "name": "account_tokens_userId_users_id_fk", + "tableFrom": "account_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_batches": { + "name": "transfer_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storedAt": { + "name": "storedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "transfer_batches_requestId_unique": { + "name": "transfer_batches_requestId_unique", + "columns": [ + "requestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "transfer_batches_requestId_transfer_requests_id_fk": { + "name": "transfer_batches_requestId_transfer_requests_id_fk", + "tableFrom": "transfer_batches", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guest_blocks": { + "name": "guest_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetGuestId": { + "name": "targetGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_block_pair": { + "name": "guest_block_pair", + "columns": [ + "guestId", + "targetGuestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guest_blocks_guestId_guest_sessions_id_fk": { + "name": "guest_blocks_guestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "guest_blocks_targetGuestId_guest_sessions_id_fk": { + "name": "guest_blocks_targetGuestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "targetGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_targets": { + "name": "deletion_targets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operationId": { + "name": "operationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessId": { + "name": "accessId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_targets_operationId_deletion_operations_id_fk": { + "name": "deletion_targets_operationId_deletion_operations_id_fk", + "tableFrom": "deletion_targets", + "tableTo": "deletion_operations", + "columnsFrom": [ + "operationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_operations": { + "name": "deletion_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undoUntil": { + "name": "undoUntil", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "restoredAt": { + "name": "restoredAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_operations_userId_users_id_fk": { + "name": "deletion_operations_userId_users_id_fk", + "tableFrom": "deletion_operations", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deletion_operations_guestId_guest_sessions_id_fk": { + "name": "deletion_operations_guestId_guest_sessions_id_fk", + "tableFrom": "deletion_operations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "deletion_subject": { + "name": "deletion_subject", + "value": "(\"deletion_operations\".\"guestId\" IS NOT NULL) + (\"deletion_operations\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "browser_devices": { + "name": "browser_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discoverable": { + "name": "discoverable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_devices_guestId_unique": { + "name": "browser_devices_guestId_unique", + "columns": [ + "guestId" + ], + "isUnique": true + }, + "browser_devices_code_unique": { + "name": "browser_devices_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "browser_devices_guestId_guest_sessions_id_fk": { + "name": "browser_devices_guestId_guest_sessions_id_fk", + "tableFrom": "browser_devices", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "device_name_length": { + "name": "device_name_length", + "value": "length(\"browser_devices\".\"name\") between 1 and 40" + } + } + }, + "files": { + "name": "files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerGuestId": { + "name": "ownerGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownerUserId": { + "name": "ownerUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storageKey": { + "name": "storageKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadId": { + "name": "uploadId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadKey": { + "name": "uploadKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "files_storageKey_unique": { + "name": "files_storageKey_unique", + "columns": [ + "storageKey" + ], + "isUnique": true + }, + "request_file_position": { + "name": "request_file_position", + "columns": [ + "requestId", + "position" + ], + "isUnique": true + }, + "files_owner_state": { + "name": "files_owner_state", + "columns": [ + "ownerGuestId", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "files_requestId_transfer_requests_id_fk": { + "name": "files_requestId_transfer_requests_id_fk", + "tableFrom": "files", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_ownerGuestId_guest_sessions_id_fk": { + "name": "files_ownerGuestId_guest_sessions_id_fk", + "tableFrom": "files", + "tableTo": "guest_sessions", + "columnsFrom": [ + "ownerGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_ownerUserId_users_id_fk": { + "name": "files_ownerUserId_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "ownerUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "file_nonnegative_size": { + "name": "file_nonnegative_size", + "value": "\"files\".\"size\" >= 0" + }, + "file_owner_subject": { + "name": "file_owner_subject", + "value": "(\"files\".\"ownerGuestId\" IS NOT NULL) + (\"files\".\"ownerUserId\" IS NOT NULL) = 1" + } + } + }, + "guest_sessions": { + "name": "guest_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_sessions_tokenHash_unique": { + "name": "guest_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "guest_expiry": { + "name": "guest_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idempotency_records": { + "name": "idempotency_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payloadHash": { + "name": "payloadHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idempotency_scope": { + "name": "idempotency_scope", + "columns": [ + "guestId", + "operation", + "key" + ], + "isUnique": true + }, + "account_idempotency_scope": { + "name": "account_idempotency_scope", + "columns": [ + "userId", + "operation", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "idempotency_records_userId_users_id_fk": { + "name": "idempotency_records_userId_users_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "idempotency_records_guestId_guest_sessions_id_fk": { + "name": "idempotency_records_guestId_guest_sessions_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "idempotency_subject": { + "name": "idempotency_subject", + "value": "(\"idempotency_records\".\"guestId\" IS NOT NULL) + (\"idempotency_records\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "batchId": { + "name": "batchId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "items_expiry": { + "name": "items_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "items_batchId_transfer_batches_id_fk": { + "name": "items_batchId_transfer_batches_id_fk", + "tableFrom": "items", + "tableTo": "transfer_batches", + "columnsFrom": [ + "batchId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "items_fileId_files_id_fk": { + "name": "items_fileId_files_id_fk", + "tableFrom": "items", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "item_payload": { + "name": "item_payload", + "value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'file' AND \"items\".\"fileId\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"url\" IS NULL)" + } + } + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joinedAt": { + "name": "joinedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "space_device": { + "name": "space_device", + "columns": [ + "spaceId", + "deviceId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "space_members_spaceId_connection_spaces_id_fk": { + "name": "space_members_spaceId_connection_spaces_id_fk", + "tableFrom": "space_members", + "tableTo": "connection_spaces", + "columnsFrom": [ + "spaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "space_members_deviceId_browser_devices_id_fk": { + "name": "space_members_deviceId_browser_devices_id_fk", + "tableFrom": "space_members", + "tableTo": "browser_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "request_receipt_accounts": { + "name": "request_receipt_accounts", + "columns": { + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "request_receipt_accounts_requestId_transfer_requests_id_fk": { + "name": "request_receipt_accounts_requestId_transfer_requests_id_fk", + "tableFrom": "request_receipt_accounts", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "request_receipt_accounts_userId_users_id_fk": { + "name": "request_receipt_accounts_userId_users_id_fk", + "tableFrom": "request_receipt_accounts", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "request_receipt_accounts_sessionId_account_sessions_id_fk": { + "name": "request_receipt_accounts_sessionId_account_sessions_id_fk", + "tableFrom": "request_receipt_accounts", + "tableTo": "account_sessions", + "columnsFrom": [ + "sessionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_requests": { + "name": "transfer_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "senderGuestId": { + "name": "senderGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientGuestId": { + "name": "recipientGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "senderUserId": { + "name": "senderUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientUserId": { + "name": "recipientUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byteSize": { + "name": "byteSize", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejectedAt": { + "name": "rejectedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startBy": { + "name": "startBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "requests_sender_time": { + "name": "requests_sender_time", + "columns": [ + "senderGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_time": { + "name": "requests_recipient_time", + "columns": [ + "recipientGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_expiry": { + "name": "requests_expiry", + "columns": [ + "status", + "expiresAt" + ], + "isUnique": false + }, + "requests_sender_user": { + "name": "requests_sender_user", + "columns": [ + "senderUserId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_user": { + "name": "requests_recipient_user", + "columns": [ + "recipientUserId", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_requests_senderGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_senderGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "senderGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_recipientGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "recipientGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_senderUserId_users_id_fk": { + "name": "transfer_requests_senderUserId_users_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "users", + "columnsFrom": [ + "senderUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientUserId_users_id_fk": { + "name": "transfer_requests_recipientUserId_users_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "users", + "columnsFrom": [ + "recipientUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "request_different_guests": { + "name": "request_different_guests", + "value": "\"transfer_requests\".\"mode\" <> 'local' OR \"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\"" + }, + "request_sender_subject": { + "name": "request_sender_subject", + "value": "(\"transfer_requests\".\"senderGuestId\" IS NOT NULL) + (\"transfer_requests\".\"senderUserId\" IS NOT NULL) = 1" + }, + "request_recipient_subject": { + "name": "request_recipient_subject", + "value": "(\"transfer_requests\".\"recipientGuestId\" IS NOT NULL) + (\"transfer_requests\".\"recipientUserId\" IS NOT NULL) = 1" + }, + "request_mode_subjects": { + "name": "request_mode_subjects", + "value": "(\"transfer_requests\".\"mode\" = 'local' AND \"transfer_requests\".\"senderGuestId\" IS NOT NULL AND \"transfer_requests\".\"recipientGuestId\" IS NOT NULL) OR (\"transfer_requests\".\"mode\" = 'email' AND \"transfer_requests\".\"senderUserId\" IS NOT NULL AND \"transfer_requests\".\"recipientUserId\" IS NOT NULL)" + } + } + }, + "quota_reservations": { + "name": "quota_reservations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "quota_reservations_fileId_unique": { + "name": "quota_reservations_fileId_unique", + "columns": [ + "fileId" + ], + "isUnique": true + }, + "quota_guest_expiry": { + "name": "quota_guest_expiry", + "columns": [ + "guestId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "quota_reservations_fileId_files_id_fk": { + "name": "quota_reservations_fileId_files_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quota_reservations_userId_users_id_fk": { + "name": "quota_reservations_userId_users_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quota_reservations_guestId_guest_sessions_id_fk": { + "name": "quota_reservations_guestId_guest_sessions_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "reservations_subject": { + "name": "reservations_subject", + "value": "(\"quota_reservations\".\"guestId\" IS NOT NULL) + (\"quota_reservations\".\"userId\" IS NOT NULL) = 1" + }, + "reservation_nonnegative": { + "name": "reservation_nonnegative", + "value": "\"quota_reservations\".\"bytes\" >= 0" + } + } + }, + "user_rules": { + "name": "user_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetUserId": { + "name": "targetUserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_rule_pair": { + "name": "user_rule_pair", + "columns": [ + "userId", + "targetUserId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_rules_userId_users_id_fk": { + "name": "user_rules_userId_users_id_fk", + "tableFrom": "user_rules", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_rules_targetUserId_users_id_fk": { + "name": "user_rules_targetUserId_users_id_fk", + "tableFrom": "user_rules", + "tableTo": "users", + "columnsFrom": [ + "targetUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "rule_not_self": { + "name": "rule_not_self", + "value": "\"user_rules\".\"userId\" <> \"user_rules\".\"targetUserId\"" + } + } + }, + "connection_spaces": { + "name": "connection_spaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "creatorGuestId": { + "name": "creatorGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approvalRequired": { + "name": "approvalRequired", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closedAt": { + "name": "closedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_spaces_tokenHash_unique": { + "name": "connection_spaces_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "connection_spaces_codeHash_unique": { + "name": "connection_spaces_codeHash_unique", + "columns": [ + "codeHash" + ], + "isUnique": true + }, + "space_expiry": { + "name": "space_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "connection_spaces_creatorGuestId_guest_sessions_id_fk": { + "name": "connection_spaces_creatorGuestId_guest_sessions_id_fk", + "tableFrom": "connection_spaces", + "tableTo": "guest_sessions", + "columnsFrom": [ + "creatorGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verifiedAt": { + "name": "verifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "receivePolicy": { + "name": "receivePolicy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requests'" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_transfers": { + "name": "vault_transfers", + "columns": { + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "accessId": { + "name": "accessId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "vault_transfers_requestId_transfer_requests_id_fk": { + "name": "vault_transfers_requestId_transfer_requests_id_fk", + "tableFrom": "vault_transfers", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "vault_transfers_accessId_item_access_id_fk": { + "name": "vault_transfers_accessId_item_access_id_fk", + "tableFrom": "vault_transfers", + "tableTo": "item_access", + "columnsFrom": [ + "accessId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "vault_transfers_userId_users_id_fk": { + "name": "vault_transfers_userId_users_id_fk", + "tableFrom": "vault_transfers", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "vault_transfers_sessionId_account_sessions_id_fk": { + "name": "vault_transfers_sessionId_account_sessions_id_fk", + "tableFrom": "vault_transfers", + "tableTo": "account_sessions", + "columnsFrom": [ + "sessionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/0010_snapshot.json b/apps/server/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..629d28d --- /dev/null +++ b/apps/server/drizzle/meta/0010_snapshot.json @@ -0,0 +1,2029 @@ +{ + "id": "1c5afea8-d9f7-4452-83c2-e6f8196cec91", + "prevId": "4d2a3ed2-4aab-4fe7-92c0-b0bd8be524c3", + "version": "6", + "dialect": "sqlite", + "tables": { + "item_access": { + "name": "item_access", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "itemId": { + "name": "itemId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favorite": { + "name": "favorite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "access_subject_role": { + "name": "access_subject_role", + "columns": [ + "itemId", + "guestId", + "role" + ], + "isUnique": true + }, + "access_account_role": { + "name": "access_account_role", + "columns": [ + "itemId", + "userId", + "role" + ], + "isUnique": true + }, + "access_account": { + "name": "access_account", + "columns": [ + "userId", + "deletedAt" + ], + "isUnique": false + }, + "access_guest": { + "name": "access_guest", + "columns": [ + "guestId", + "deletedAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "item_access_itemId_items_id_fk": { + "name": "item_access_itemId_items_id_fk", + "tableFrom": "item_access", + "columnsFrom": [ + "itemId" + ], + "tableTo": "items", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "item_access_userId_users_id_fk": { + "name": "item_access_userId_users_id_fk", + "tableFrom": "item_access", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "item_access_guestId_guest_sessions_id_fk": { + "name": "item_access_guestId_guest_sessions_id_fk", + "tableFrom": "item_access", + "columnsFrom": [ + "guestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "access_subject": { + "name": "access_subject", + "value": "(\"item_access\".\"guestId\" IS NOT NULL) + (\"item_access\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "account_sessions": { + "name": "account_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_sessions_tokenHash_unique": { + "name": "account_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_session_user": { + "name": "account_session_user", + "columns": [ + "userId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_sessions_userId_users_id_fk": { + "name": "account_sessions_userId_users_id_fk", + "tableFrom": "account_sessions", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account_tokens": { + "name": "account_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_tokens_tokenHash_unique": { + "name": "account_tokens_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_token_expiry": { + "name": "account_token_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_tokens_userId_users_id_fk": { + "name": "account_tokens_userId_users_id_fk", + "tableFrom": "account_tokens", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_batches": { + "name": "transfer_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storedAt": { + "name": "storedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "transfer_batches_requestId_unique": { + "name": "transfer_batches_requestId_unique", + "columns": [ + "requestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "transfer_batches_requestId_transfer_requests_id_fk": { + "name": "transfer_batches_requestId_transfer_requests_id_fk", + "tableFrom": "transfer_batches", + "columnsFrom": [ + "requestId" + ], + "tableTo": "transfer_requests", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guest_blocks": { + "name": "guest_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetGuestId": { + "name": "targetGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_block_pair": { + "name": "guest_block_pair", + "columns": [ + "guestId", + "targetGuestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guest_blocks_guestId_guest_sessions_id_fk": { + "name": "guest_blocks_guestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "columnsFrom": [ + "guestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "guest_blocks_targetGuestId_guest_sessions_id_fk": { + "name": "guest_blocks_targetGuestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "columnsFrom": [ + "targetGuestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_targets": { + "name": "deletion_targets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operationId": { + "name": "operationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessId": { + "name": "accessId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_targets_operationId_deletion_operations_id_fk": { + "name": "deletion_targets_operationId_deletion_operations_id_fk", + "tableFrom": "deletion_targets", + "columnsFrom": [ + "operationId" + ], + "tableTo": "deletion_operations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_operations": { + "name": "deletion_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undoUntil": { + "name": "undoUntil", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "restoredAt": { + "name": "restoredAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_operations_userId_users_id_fk": { + "name": "deletion_operations_userId_users_id_fk", + "tableFrom": "deletion_operations", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "deletion_operations_guestId_guest_sessions_id_fk": { + "name": "deletion_operations_guestId_guest_sessions_id_fk", + "tableFrom": "deletion_operations", + "columnsFrom": [ + "guestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "deletion_subject": { + "name": "deletion_subject", + "value": "(\"deletion_operations\".\"guestId\" IS NOT NULL) + (\"deletion_operations\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "browser_devices": { + "name": "browser_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discoverable": { + "name": "discoverable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_devices_guestId_unique": { + "name": "browser_devices_guestId_unique", + "columns": [ + "guestId" + ], + "isUnique": true + }, + "browser_devices_code_unique": { + "name": "browser_devices_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "browser_devices_guestId_guest_sessions_id_fk": { + "name": "browser_devices_guestId_guest_sessions_id_fk", + "tableFrom": "browser_devices", + "columnsFrom": [ + "guestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "device_name_length": { + "name": "device_name_length", + "value": "length(\"browser_devices\".\"name\") between 1 and 40" + } + } + }, + "files": { + "name": "files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerGuestId": { + "name": "ownerGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownerUserId": { + "name": "ownerUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storageKey": { + "name": "storageKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadId": { + "name": "uploadId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadKey": { + "name": "uploadKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "files_storageKey_unique": { + "name": "files_storageKey_unique", + "columns": [ + "storageKey" + ], + "isUnique": true + }, + "request_file_position": { + "name": "request_file_position", + "columns": [ + "requestId", + "position" + ], + "isUnique": true + }, + "files_owner_state": { + "name": "files_owner_state", + "columns": [ + "ownerGuestId", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "files_requestId_transfer_requests_id_fk": { + "name": "files_requestId_transfer_requests_id_fk", + "tableFrom": "files", + "columnsFrom": [ + "requestId" + ], + "tableTo": "transfer_requests", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "files_ownerGuestId_guest_sessions_id_fk": { + "name": "files_ownerGuestId_guest_sessions_id_fk", + "tableFrom": "files", + "columnsFrom": [ + "ownerGuestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "files_ownerUserId_users_id_fk": { + "name": "files_ownerUserId_users_id_fk", + "tableFrom": "files", + "columnsFrom": [ + "ownerUserId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "file_nonnegative_size": { + "name": "file_nonnegative_size", + "value": "\"files\".\"size\" >= 0" + }, + "file_owner_subject": { + "name": "file_owner_subject", + "value": "(\"files\".\"ownerGuestId\" IS NOT NULL) + (\"files\".\"ownerUserId\" IS NOT NULL) = 1" + } + } + }, + "guest_sessions": { + "name": "guest_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_sessions_tokenHash_unique": { + "name": "guest_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "guest_expiry": { + "name": "guest_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idempotency_records": { + "name": "idempotency_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payloadHash": { + "name": "payloadHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idempotency_scope": { + "name": "idempotency_scope", + "columns": [ + "guestId", + "operation", + "key" + ], + "isUnique": true + }, + "account_idempotency_scope": { + "name": "account_idempotency_scope", + "columns": [ + "userId", + "operation", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "idempotency_records_userId_users_id_fk": { + "name": "idempotency_records_userId_users_id_fk", + "tableFrom": "idempotency_records", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "idempotency_records_guestId_guest_sessions_id_fk": { + "name": "idempotency_records_guestId_guest_sessions_id_fk", + "tableFrom": "idempotency_records", + "columnsFrom": [ + "guestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "idempotency_subject": { + "name": "idempotency_subject", + "value": "(\"idempotency_records\".\"guestId\" IS NOT NULL) + (\"idempotency_records\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "batchId": { + "name": "batchId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "items_expiry": { + "name": "items_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "items_batchId_transfer_batches_id_fk": { + "name": "items_batchId_transfer_batches_id_fk", + "tableFrom": "items", + "columnsFrom": [ + "batchId" + ], + "tableTo": "transfer_batches", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "items_fileId_files_id_fk": { + "name": "items_fileId_files_id_fk", + "tableFrom": "items", + "columnsFrom": [ + "fileId" + ], + "tableTo": "files", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "item_payload": { + "name": "item_payload", + "value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'file' AND \"items\".\"fileId\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"url\" IS NULL)" + } + } + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joinedAt": { + "name": "joinedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "space_device": { + "name": "space_device", + "columns": [ + "spaceId", + "deviceId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "space_members_spaceId_connection_spaces_id_fk": { + "name": "space_members_spaceId_connection_spaces_id_fk", + "tableFrom": "space_members", + "columnsFrom": [ + "spaceId" + ], + "tableTo": "connection_spaces", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "space_members_deviceId_browser_devices_id_fk": { + "name": "space_members_deviceId_browser_devices_id_fk", + "tableFrom": "space_members", + "columnsFrom": [ + "deviceId" + ], + "tableTo": "browser_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "request_receipt_accounts": { + "name": "request_receipt_accounts", + "columns": { + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "request_receipt_accounts_requestId_transfer_requests_id_fk": { + "name": "request_receipt_accounts_requestId_transfer_requests_id_fk", + "tableFrom": "request_receipt_accounts", + "columnsFrom": [ + "requestId" + ], + "tableTo": "transfer_requests", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "request_receipt_accounts_userId_users_id_fk": { + "name": "request_receipt_accounts_userId_users_id_fk", + "tableFrom": "request_receipt_accounts", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "request_receipt_accounts_sessionId_account_sessions_id_fk": { + "name": "request_receipt_accounts_sessionId_account_sessions_id_fk", + "tableFrom": "request_receipt_accounts", + "columnsFrom": [ + "sessionId" + ], + "tableTo": "account_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_requests": { + "name": "transfer_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "senderGuestId": { + "name": "senderGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientGuestId": { + "name": "recipientGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "senderUserId": { + "name": "senderUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientUserId": { + "name": "recipientUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byteSize": { + "name": "byteSize", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejectedAt": { + "name": "rejectedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startBy": { + "name": "startBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "requests_sender_time": { + "name": "requests_sender_time", + "columns": [ + "senderGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_time": { + "name": "requests_recipient_time", + "columns": [ + "recipientGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_expiry": { + "name": "requests_expiry", + "columns": [ + "status", + "expiresAt" + ], + "isUnique": false + }, + "requests_sender_user": { + "name": "requests_sender_user", + "columns": [ + "senderUserId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_user": { + "name": "requests_recipient_user", + "columns": [ + "recipientUserId", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_requests_senderGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_senderGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "columnsFrom": [ + "senderGuestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "transfer_requests_recipientGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_recipientGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "columnsFrom": [ + "recipientGuestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "transfer_requests_senderUserId_users_id_fk": { + "name": "transfer_requests_senderUserId_users_id_fk", + "tableFrom": "transfer_requests", + "columnsFrom": [ + "senderUserId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "transfer_requests_recipientUserId_users_id_fk": { + "name": "transfer_requests_recipientUserId_users_id_fk", + "tableFrom": "transfer_requests", + "columnsFrom": [ + "recipientUserId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "request_different_guests": { + "name": "request_different_guests", + "value": "\"transfer_requests\".\"mode\" <> 'local' OR \"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\"" + }, + "request_sender_subject": { + "name": "request_sender_subject", + "value": "(\"transfer_requests\".\"senderGuestId\" IS NOT NULL) + (\"transfer_requests\".\"senderUserId\" IS NOT NULL) = 1" + }, + "request_recipient_subject": { + "name": "request_recipient_subject", + "value": "(\"transfer_requests\".\"recipientGuestId\" IS NOT NULL) + (\"transfer_requests\".\"recipientUserId\" IS NOT NULL) = 1" + }, + "request_mode_subjects": { + "name": "request_mode_subjects", + "value": "(\"transfer_requests\".\"mode\" = 'local' AND \"transfer_requests\".\"senderGuestId\" IS NOT NULL AND \"transfer_requests\".\"recipientGuestId\" IS NOT NULL) OR (\"transfer_requests\".\"mode\" = 'email' AND \"transfer_requests\".\"senderUserId\" IS NOT NULL AND \"transfer_requests\".\"recipientUserId\" IS NOT NULL)" + } + } + }, + "quota_reservations": { + "name": "quota_reservations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "quota_reservations_fileId_unique": { + "name": "quota_reservations_fileId_unique", + "columns": [ + "fileId" + ], + "isUnique": true + }, + "quota_guest_expiry": { + "name": "quota_guest_expiry", + "columns": [ + "guestId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "quota_reservations_fileId_files_id_fk": { + "name": "quota_reservations_fileId_files_id_fk", + "tableFrom": "quota_reservations", + "columnsFrom": [ + "fileId" + ], + "tableTo": "files", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "quota_reservations_userId_users_id_fk": { + "name": "quota_reservations_userId_users_id_fk", + "tableFrom": "quota_reservations", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "quota_reservations_guestId_guest_sessions_id_fk": { + "name": "quota_reservations_guestId_guest_sessions_id_fk", + "tableFrom": "quota_reservations", + "columnsFrom": [ + "guestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "reservations_subject": { + "name": "reservations_subject", + "value": "(\"quota_reservations\".\"guestId\" IS NOT NULL) + (\"quota_reservations\".\"userId\" IS NOT NULL) = 1" + }, + "reservation_nonnegative": { + "name": "reservation_nonnegative", + "value": "\"quota_reservations\".\"bytes\" >= 0" + } + } + }, + "user_rules": { + "name": "user_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetUserId": { + "name": "targetUserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_rule_pair": { + "name": "user_rule_pair", + "columns": [ + "userId", + "targetUserId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_rules_userId_users_id_fk": { + "name": "user_rules_userId_users_id_fk", + "tableFrom": "user_rules", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "user_rules_targetUserId_users_id_fk": { + "name": "user_rules_targetUserId_users_id_fk", + "tableFrom": "user_rules", + "columnsFrom": [ + "targetUserId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "rule_not_self": { + "name": "rule_not_self", + "value": "\"user_rules\".\"userId\" <> \"user_rules\".\"targetUserId\"" + } + } + }, + "connection_spaces": { + "name": "connection_spaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "creatorGuestId": { + "name": "creatorGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approvalRequired": { + "name": "approvalRequired", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closedAt": { + "name": "closedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_spaces_tokenHash_unique": { + "name": "connection_spaces_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "connection_spaces_codeHash_unique": { + "name": "connection_spaces_codeHash_unique", + "columns": [ + "codeHash" + ], + "isUnique": true + }, + "space_expiry": { + "name": "space_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "connection_spaces_creatorGuestId_guest_sessions_id_fk": { + "name": "connection_spaces_creatorGuestId_guest_sessions_id_fk", + "tableFrom": "connection_spaces", + "columnsFrom": [ + "creatorGuestId" + ], + "tableTo": "guest_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verifiedAt": { + "name": "verifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "receivePolicy": { + "name": "receivePolicy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requests'" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_transfers": { + "name": "vault_transfers", + "columns": { + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "accessId": { + "name": "accessId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "vault_transfers_requestId_transfer_requests_id_fk": { + "name": "vault_transfers_requestId_transfer_requests_id_fk", + "tableFrom": "vault_transfers", + "columnsFrom": [ + "requestId" + ], + "tableTo": "transfer_requests", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "vault_transfers_accessId_item_access_id_fk": { + "name": "vault_transfers_accessId_item_access_id_fk", + "tableFrom": "vault_transfers", + "columnsFrom": [ + "accessId" + ], + "tableTo": "item_access", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "vault_transfers_userId_users_id_fk": { + "name": "vault_transfers_userId_users_id_fk", + "tableFrom": "vault_transfers", + "columnsFrom": [ + "userId" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "vault_transfers_sessionId_account_sessions_id_fk": { + "name": "vault_transfers_sessionId_account_sessions_id_fk", + "tableFrom": "vault_transfers", + "columnsFrom": [ + "sessionId" + ], + "tableTo": "account_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/0011_snapshot.json b/apps/server/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..6cecde3 --- /dev/null +++ b/apps/server/drizzle/meta/0011_snapshot.json @@ -0,0 +1,2037 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2c9e453d-342c-448e-a64a-92d234803864", + "prevId": "1c5afea8-d9f7-4452-83c2-e6f8196cec91", + "tables": { + "item_access": { + "name": "item_access", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "itemId": { + "name": "itemId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favorite": { + "name": "favorite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "access_subject_role": { + "name": "access_subject_role", + "columns": [ + "itemId", + "guestId", + "role" + ], + "isUnique": true + }, + "access_account_role": { + "name": "access_account_role", + "columns": [ + "itemId", + "userId", + "role" + ], + "isUnique": true + }, + "access_account": { + "name": "access_account", + "columns": [ + "userId", + "deletedAt" + ], + "isUnique": false + }, + "access_guest": { + "name": "access_guest", + "columns": [ + "guestId", + "deletedAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "item_access_itemId_items_id_fk": { + "name": "item_access_itemId_items_id_fk", + "tableFrom": "item_access", + "tableTo": "items", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_userId_users_id_fk": { + "name": "item_access_userId_users_id_fk", + "tableFrom": "item_access", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "item_access_guestId_guest_sessions_id_fk": { + "name": "item_access_guestId_guest_sessions_id_fk", + "tableFrom": "item_access", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "access_subject": { + "name": "access_subject", + "value": "(\"item_access\".\"guestId\" IS NOT NULL) + (\"item_access\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "account_sessions": { + "name": "account_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_sessions_tokenHash_unique": { + "name": "account_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_session_user": { + "name": "account_session_user", + "columns": [ + "userId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_sessions_userId_users_id_fk": { + "name": "account_sessions_userId_users_id_fk", + "tableFrom": "account_sessions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account_tokens": { + "name": "account_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_tokens_tokenHash_unique": { + "name": "account_tokens_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "account_token_expiry": { + "name": "account_token_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_tokens_userId_users_id_fk": { + "name": "account_tokens_userId_users_id_fk", + "tableFrom": "account_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_batches": { + "name": "transfer_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storedAt": { + "name": "storedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "transfer_batches_requestId_unique": { + "name": "transfer_batches_requestId_unique", + "columns": [ + "requestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "transfer_batches_requestId_transfer_requests_id_fk": { + "name": "transfer_batches_requestId_transfer_requests_id_fk", + "tableFrom": "transfer_batches", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guest_blocks": { + "name": "guest_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetGuestId": { + "name": "targetGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_block_pair": { + "name": "guest_block_pair", + "columns": [ + "guestId", + "targetGuestId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guest_blocks_guestId_guest_sessions_id_fk": { + "name": "guest_blocks_guestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "guest_blocks_targetGuestId_guest_sessions_id_fk": { + "name": "guest_blocks_targetGuestId_guest_sessions_id_fk", + "tableFrom": "guest_blocks", + "tableTo": "guest_sessions", + "columnsFrom": [ + "targetGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_targets": { + "name": "deletion_targets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operationId": { + "name": "operationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessId": { + "name": "accessId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_targets_operationId_deletion_operations_id_fk": { + "name": "deletion_targets_operationId_deletion_operations_id_fk", + "tableFrom": "deletion_targets", + "tableTo": "deletion_operations", + "columnsFrom": [ + "operationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deletion_operations": { + "name": "deletion_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undoUntil": { + "name": "undoUntil", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "restoredAt": { + "name": "restoredAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_operations_userId_users_id_fk": { + "name": "deletion_operations_userId_users_id_fk", + "tableFrom": "deletion_operations", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deletion_operations_guestId_guest_sessions_id_fk": { + "name": "deletion_operations_guestId_guest_sessions_id_fk", + "tableFrom": "deletion_operations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "deletion_subject": { + "name": "deletion_subject", + "value": "(\"deletion_operations\".\"guestId\" IS NOT NULL) + (\"deletion_operations\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "browser_devices": { + "name": "browser_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discoverable": { + "name": "discoverable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_devices_guestId_unique": { + "name": "browser_devices_guestId_unique", + "columns": [ + "guestId" + ], + "isUnique": true + }, + "browser_devices_code_unique": { + "name": "browser_devices_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "browser_devices_guestId_guest_sessions_id_fk": { + "name": "browser_devices_guestId_guest_sessions_id_fk", + "tableFrom": "browser_devices", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "device_name_length": { + "name": "device_name_length", + "value": "length(\"browser_devices\".\"name\") between 1 and 40" + } + } + }, + "files": { + "name": "files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerGuestId": { + "name": "ownerGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownerUserId": { + "name": "ownerUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storageKey": { + "name": "storageKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadId": { + "name": "uploadId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadKey": { + "name": "uploadKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "files_storageKey_unique": { + "name": "files_storageKey_unique", + "columns": [ + "storageKey" + ], + "isUnique": true + }, + "request_file_position": { + "name": "request_file_position", + "columns": [ + "requestId", + "position" + ], + "isUnique": true + }, + "files_owner_state": { + "name": "files_owner_state", + "columns": [ + "ownerGuestId", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "files_requestId_transfer_requests_id_fk": { + "name": "files_requestId_transfer_requests_id_fk", + "tableFrom": "files", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_ownerGuestId_guest_sessions_id_fk": { + "name": "files_ownerGuestId_guest_sessions_id_fk", + "tableFrom": "files", + "tableTo": "guest_sessions", + "columnsFrom": [ + "ownerGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_ownerUserId_users_id_fk": { + "name": "files_ownerUserId_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "ownerUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "file_nonnegative_size": { + "name": "file_nonnegative_size", + "value": "\"files\".\"size\" >= 0" + }, + "file_owner_subject": { + "name": "file_owner_subject", + "value": "(\"files\".\"ownerGuestId\" IS NOT NULL) + (\"files\".\"ownerUserId\" IS NOT NULL) = 1" + } + } + }, + "guest_sessions": { + "name": "guest_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "guest_sessions_tokenHash_unique": { + "name": "guest_sessions_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "guest_expiry": { + "name": "guest_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idempotency_records": { + "name": "idempotency_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payloadHash": { + "name": "payloadHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idempotency_scope": { + "name": "idempotency_scope", + "columns": [ + "guestId", + "operation", + "key" + ], + "isUnique": true + }, + "account_idempotency_scope": { + "name": "account_idempotency_scope", + "columns": [ + "userId", + "operation", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "idempotency_records_userId_users_id_fk": { + "name": "idempotency_records_userId_users_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "idempotency_records_guestId_guest_sessions_id_fk": { + "name": "idempotency_records_guestId_guest_sessions_id_fk", + "tableFrom": "idempotency_records", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "idempotency_subject": { + "name": "idempotency_subject", + "value": "(\"idempotency_records\".\"guestId\" IS NOT NULL) + (\"idempotency_records\".\"userId\" IS NOT NULL) = 1" + } + } + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "batchId": { + "name": "batchId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "items_expiry": { + "name": "items_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "items_batchId_transfer_batches_id_fk": { + "name": "items_batchId_transfer_batches_id_fk", + "tableFrom": "items", + "tableTo": "transfer_batches", + "columnsFrom": [ + "batchId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "items_fileId_files_id_fk": { + "name": "items_fileId_files_id_fk", + "tableFrom": "items", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "item_payload": { + "name": "item_payload", + "value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"fileId\" IS NULL) OR (\"items\".\"kind\" = 'file' AND \"items\".\"fileId\" IS NOT NULL AND \"items\".\"text\" IS NULL AND \"items\".\"url\" IS NULL)" + } + } + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joinedAt": { + "name": "joinedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "space_device": { + "name": "space_device", + "columns": [ + "spaceId", + "deviceId" + ], + "isUnique": true + }, + "member_single_active_device": { + "name": "member_single_active_device", + "columns": [ + "deviceId" + ], + "isUnique": true, + "where": "\"space_members\".\"status\" IN ('pending', 'approved')" + } + }, + "foreignKeys": { + "space_members_spaceId_connection_spaces_id_fk": { + "name": "space_members_spaceId_connection_spaces_id_fk", + "tableFrom": "space_members", + "tableTo": "connection_spaces", + "columnsFrom": [ + "spaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "space_members_deviceId_browser_devices_id_fk": { + "name": "space_members_deviceId_browser_devices_id_fk", + "tableFrom": "space_members", + "tableTo": "browser_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "request_receipt_accounts": { + "name": "request_receipt_accounts", + "columns": { + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "request_receipt_accounts_requestId_transfer_requests_id_fk": { + "name": "request_receipt_accounts_requestId_transfer_requests_id_fk", + "tableFrom": "request_receipt_accounts", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "request_receipt_accounts_userId_users_id_fk": { + "name": "request_receipt_accounts_userId_users_id_fk", + "tableFrom": "request_receipt_accounts", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "request_receipt_accounts_sessionId_account_sessions_id_fk": { + "name": "request_receipt_accounts_sessionId_account_sessions_id_fk", + "tableFrom": "request_receipt_accounts", + "tableTo": "account_sessions", + "columnsFrom": [ + "sessionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_requests": { + "name": "transfer_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "senderGuestId": { + "name": "senderGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientGuestId": { + "name": "recipientGuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "senderUserId": { + "name": "senderUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientUserId": { + "name": "recipientUserId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byteSize": { + "name": "byteSize", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejectedAt": { + "name": "rejectedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startBy": { + "name": "startBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "requests_sender_time": { + "name": "requests_sender_time", + "columns": [ + "senderGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_time": { + "name": "requests_recipient_time", + "columns": [ + "recipientGuestId", + "createdAt" + ], + "isUnique": false + }, + "requests_expiry": { + "name": "requests_expiry", + "columns": [ + "status", + "expiresAt" + ], + "isUnique": false + }, + "requests_sender_user": { + "name": "requests_sender_user", + "columns": [ + "senderUserId", + "createdAt" + ], + "isUnique": false + }, + "requests_recipient_user": { + "name": "requests_recipient_user", + "columns": [ + "recipientUserId", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_requests_senderGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_senderGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "senderGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientGuestId_guest_sessions_id_fk": { + "name": "transfer_requests_recipientGuestId_guest_sessions_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "guest_sessions", + "columnsFrom": [ + "recipientGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_senderUserId_users_id_fk": { + "name": "transfer_requests_senderUserId_users_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "users", + "columnsFrom": [ + "senderUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transfer_requests_recipientUserId_users_id_fk": { + "name": "transfer_requests_recipientUserId_users_id_fk", + "tableFrom": "transfer_requests", + "tableTo": "users", + "columnsFrom": [ + "recipientUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "request_different_guests": { + "name": "request_different_guests", + "value": "\"transfer_requests\".\"mode\" <> 'local' OR \"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\"" + }, + "request_sender_subject": { + "name": "request_sender_subject", + "value": "(\"transfer_requests\".\"senderGuestId\" IS NOT NULL) + (\"transfer_requests\".\"senderUserId\" IS NOT NULL) = 1" + }, + "request_recipient_subject": { + "name": "request_recipient_subject", + "value": "(\"transfer_requests\".\"recipientGuestId\" IS NOT NULL) + (\"transfer_requests\".\"recipientUserId\" IS NOT NULL) = 1" + }, + "request_mode_subjects": { + "name": "request_mode_subjects", + "value": "(\"transfer_requests\".\"mode\" = 'local' AND \"transfer_requests\".\"senderGuestId\" IS NOT NULL AND \"transfer_requests\".\"recipientGuestId\" IS NOT NULL) OR (\"transfer_requests\".\"mode\" = 'email' AND \"transfer_requests\".\"senderUserId\" IS NOT NULL AND \"transfer_requests\".\"recipientUserId\" IS NOT NULL)" + } + } + }, + "quota_reservations": { + "name": "quota_reservations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "quota_reservations_fileId_unique": { + "name": "quota_reservations_fileId_unique", + "columns": [ + "fileId" + ], + "isUnique": true + }, + "quota_guest_expiry": { + "name": "quota_guest_expiry", + "columns": [ + "guestId", + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "quota_reservations_fileId_files_id_fk": { + "name": "quota_reservations_fileId_files_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quota_reservations_userId_users_id_fk": { + "name": "quota_reservations_userId_users_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quota_reservations_guestId_guest_sessions_id_fk": { + "name": "quota_reservations_guestId_guest_sessions_id_fk", + "tableFrom": "quota_reservations", + "tableTo": "guest_sessions", + "columnsFrom": [ + "guestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "reservations_subject": { + "name": "reservations_subject", + "value": "(\"quota_reservations\".\"guestId\" IS NOT NULL) + (\"quota_reservations\".\"userId\" IS NOT NULL) = 1" + }, + "reservation_nonnegative": { + "name": "reservation_nonnegative", + "value": "\"quota_reservations\".\"bytes\" >= 0" + } + } + }, + "user_rules": { + "name": "user_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targetUserId": { + "name": "targetUserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_rule_pair": { + "name": "user_rule_pair", + "columns": [ + "userId", + "targetUserId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_rules_userId_users_id_fk": { + "name": "user_rules_userId_users_id_fk", + "tableFrom": "user_rules", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_rules_targetUserId_users_id_fk": { + "name": "user_rules_targetUserId_users_id_fk", + "tableFrom": "user_rules", + "tableTo": "users", + "columnsFrom": [ + "targetUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "rule_not_self": { + "name": "rule_not_self", + "value": "\"user_rules\".\"userId\" <> \"user_rules\".\"targetUserId\"" + } + } + }, + "connection_spaces": { + "name": "connection_spaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "creatorGuestId": { + "name": "creatorGuestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approvalRequired": { + "name": "approvalRequired", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closedAt": { + "name": "closedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_spaces_tokenHash_unique": { + "name": "connection_spaces_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "connection_spaces_codeHash_unique": { + "name": "connection_spaces_codeHash_unique", + "columns": [ + "codeHash" + ], + "isUnique": true + }, + "space_expiry": { + "name": "space_expiry", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "connection_spaces_creatorGuestId_guest_sessions_id_fk": { + "name": "connection_spaces_creatorGuestId_guest_sessions_id_fk", + "tableFrom": "connection_spaces", + "tableTo": "guest_sessions", + "columnsFrom": [ + "creatorGuestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verifiedAt": { + "name": "verifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "receivePolicy": { + "name": "receivePolicy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requests'" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_transfers": { + "name": "vault_transfers", + "columns": { + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "accessId": { + "name": "accessId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "vault_transfers_requestId_transfer_requests_id_fk": { + "name": "vault_transfers_requestId_transfer_requests_id_fk", + "tableFrom": "vault_transfers", + "tableTo": "transfer_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "vault_transfers_accessId_item_access_id_fk": { + "name": "vault_transfers_accessId_item_access_id_fk", + "tableFrom": "vault_transfers", + "tableTo": "item_access", + "columnsFrom": [ + "accessId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "vault_transfers_userId_users_id_fk": { + "name": "vault_transfers_userId_users_id_fk", + "tableFrom": "vault_transfers", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "vault_transfers_sessionId_account_sessions_id_fk": { + "name": "vault_transfers_sessionId_account_sessions_id_fk", + "tableFrom": "vault_transfers", + "tableTo": "account_sessions", + "columnsFrom": [ + "sessionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json new file mode 100644 index 0000000..5a9c916 --- /dev/null +++ b/apps/server/drizzle/meta/_journal.json @@ -0,0 +1,90 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1791445972914, + "tag": "0000_aberrant_scourge", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1791447103463, + "tag": "0001_numerous_dust", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1791447352994, + "tag": "0002_orange_norrin_radd", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1791448071863, + "tag": "0003_spooky_blob", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1791449604794, + "tag": "0004_bright_wildside", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1791450987520, + "tag": "0005_tan_miracleman", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1791453590596, + "tag": "0006_file_url_only", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1791770452198, + "tag": "0007_rapid_morph", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1791770486677, + "tag": "0008_temporary-retention", + "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1791770606214, + "tag": "0009_vault-note", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1791771685461, + "tag": "0010_single-room", + "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1791771741603, + "tag": "0011_single-room-index", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..3726e4e --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,32 @@ +{ + "name": "@hop/server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "start": "node dist/main.js", + "test": "node --import tsx --test test/*.test.ts test/*.test.js", + "db:generate": "drizzle-kit generate" + }, + "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/multipart": "^9.4.0", + "@fastify/static": "^10.1.5", + "@hop/shared": "0.1.0", + "argon2": "^0.44.0", + "better-sqlite3": "^12.6.2", + "drizzle-orm": "^0.45.3", + "fastify": "^5.12.5", + "ipaddr.js": "^2.3.0", + "sharp": "^0.35.5", + "socket.io": "^4.8.3" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "drizzle-kit": "^0.31.9", + "socket.io-client": "^4.8.3", + "tsx": "^4.21.0" + } +} diff --git a/apps/server/src/accounts.ts b/apps/server/src/accounts.ts new file mode 100644 index 0000000..e61a2f7 --- /dev/null +++ b/apps/server/src/accounts.ts @@ -0,0 +1,667 @@ +import { randomUUID } from 'node:crypto'; +import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm'; +import { parseUrls, type AccountItem, type EmailRequest } from '@hop/shared'; +import type { Service, RequestRow, Guest } from './service.js'; +import type { User } from './auth.js'; +import * as s from './db/schema.js'; +import { fail, hash } from './security.js'; +export class Accounts { + constructor(readonly service: Service) {} + verified(userId: string) { + const user = this.service.auth.user(userId); + if (!this.service.auth.serialize(user).verified) + fail(403, 'VERIFICATION_REQUIRED', '이메일 인증을 완료해 주세요.'); + return user; + } + notify(userId: string) { + const io = this.service.io; + if (!io) return; + for (const socket of io.sockets.sockets.values()) { + if (socket.data.accountId !== userId) continue; + const session = this.service.db + .select() + .from(s.accountSessions) + .where(eq(s.accountSessions.id, socket.data.accountSessionId)) + .get(); + const user = this.service.auth.user(userId); + if ( + !session || + session.revokedAt || + session.expiresAt <= Date.now() || + !['pending', 'active'].includes(user.status) + ) { + socket.leave(`account:${userId}`); + socket.emit('account:revoked', {}); + } + } + io.to(`account:${userId}`).emit('inbox:updated', {}); + } + request(userId: string, id: string) { + const row = this.service.db + .select() + .from(s.requests) + .where( + and( + eq(s.requests.id, id), + eq(s.requests.mode, 'email'), + inArray(s.requests.kind, ['url', 'file']), + or(eq(s.requests.senderUserId, userId), eq(s.requests.recipientUserId, userId)), + ), + ) + .get(); + if (!row) fail(404, 'REQUEST_NOT_FOUND', '전송 요청을 찾을 수 없습니다.'); + return row!; + } + policy(senderId: string, recipientId: string) { + const recipient = this.verified(recipientId); + this.verified(senderId); + if (senderId === recipientId) return 'allow'; + const rule = this.service.db + .select() + .from(s.rules) + .where(and(eq(s.rules.userId, recipientId), eq(s.rules.targetUserId, senderId))) + .get(); + if (rule?.action === 'block' || recipient.receivePolicy === 'none') return 'block'; + if (rule?.action === 'allow') return 'allow'; + if (recipient.receivePolicy === 'allowed') return 'block'; + return 'request'; + } + canTransfer(row: RequestRow) { + try { + return ( + row.mode === 'email' && + row.status === 'accepted' && + this.policy(row.senderUserId!, row.recipientUserId!) !== 'block' + ); + } catch { + return false; + } + } + idem( + userId: string, + operation: string, + key: string | undefined, + payload: unknown, + fn: () => T, + ): T { + if (!key || !/^[A-Za-z0-9_-]{8,128}$/.test(key)) + fail(400, 'IDEMPOTENCY_REQUIRED', '유효한 중복 방지 키가 필요합니다.'); + const payloadHash = hash(JSON.stringify(payload)); + return this.service.db.transaction(() => { + const row = this.service.db + .select() + .from(s.idempotency) + .where( + and( + eq(s.idempotency.userId, userId), + eq(s.idempotency.operation, operation), + eq(s.idempotency.key, key!), + ), + ) + .get(); + if (row) { + if (row.payloadHash !== payloadHash) + fail(409, 'IDEMPOTENCY_CONFLICT', '같은 요청 키의 내용이 달라졌습니다.'); + return JSON.parse(row.response) as T; + } + const result = fn(); + this.service.db + .insert(s.idempotency) + .values({ + id: randomUUID(), + userId, + operation, + key: key!, + payloadHash, + response: JSON.stringify(result), + createdAt: Date.now(), + }) + .run(); + return result; + }); + } + create( + userId: string, + input: { + email: string; + kind: 'url' | 'file'; + byteSize: number; + count: number; + files?: { name: string; size: number; type: string }[]; + }, + ) { + const { db, cfg, limiter } = this.service; + this.verified(userId); + limiter.hit(`remote-target:${userId}`, 30, 3600000); + const recipient = db + .select() + .from(s.users) + .where( + and( + eq(s.users.email, input.email), + eq(s.users.status, 'active'), + sql`${s.users.verifiedAt} IS NOT NULL`, + ), + ) + .get(); + if (!recipient) fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.'); + let policy: string; + try { + policy = this.policy(userId, recipient!.id); + } catch { + policy = 'block'; + } + if (policy === 'block') fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.'); + if ( + input.count > cfg.maxItems || + (input.kind !== 'file' && input.byteSize > cfg.maxUrlBytes) || + input.files?.some((f) => f.size > cfg.maxFileBytes) + ) + fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.'); + if (policy === 'request') { + limiter.hit(`remote-new:${userId}`, 20, 86400000); + const pending = db + .select() + .from(s.requests) + .where( + and( + eq(s.requests.recipientUserId, recipient!.id), + eq(s.requests.status, 'pending'), + gt(s.requests.expiresAt, Date.now()), + ), + ) + .all(); + if (pending.length >= 50) fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.'); + } + const now = Date.now(), + id = randomUUID(); + db.insert(s.requests) + .values({ + id, + mode: 'email', + senderUserId: userId, + recipientUserId: recipient!.id, + kind: input.kind, + byteSize: input.byteSize, + count: input.count, + manifest: input.files ? JSON.stringify(input.files) : null, + status: policy === 'allow' ? 'accepted' : 'pending', + createdAt: now, + expiresAt: now + cfg.emailRequestDays * 86400000, + acceptedAt: policy === 'allow' ? now : null, + startBy: policy === 'allow' ? now + cfg.uploadStartHours * 3600000 : null, + }) + .run(); + db.insert(s.batches).values({ id: randomUUID(), requestId: id, status: 'waiting' }).run(); + if (input.files) { + this.service.storage.createManifest({ id: userId, kind: 'user' }, id, input.files); + if (policy === 'allow') this.service.storage.reserve({ id: userId, kind: 'user' }, id); + } + return { requestId: id, requiresAcceptance: policy !== 'allow' }; + } + accept(userId: string, id: string, allowSender = false) { + this.verified(userId); + const row = this.request(userId, id); + if (row.recipientUserId !== userId) fail(403, 'FORBIDDEN', '수신 계정만 수락할 수 있습니다.'); + if ( + row.status !== 'pending' || + row.expiresAt <= Date.now() || + this.policy(row.senderUserId!, userId) === 'block' + ) + fail(409, 'REQUEST_STATE', '수락할 수 없는 요청입니다.'); + if (row.kind === 'file') + this.service.storage.reserve({ id: row.senderUserId!, kind: 'user' }, id); + if (allowSender) this.rule(userId, row.senderUserId!, 'allow'); + const now = Date.now(); + this.service.db + .update(s.requests) + .set({ + status: 'accepted', + acceptedAt: now, + startBy: now + this.service.cfg.uploadStartHours * 3600000, + }) + .where(eq(s.requests.id, id)) + .run(); + return { requestId: id }; + } + reject(userId: string, id: string) { + this.verified(userId); + const row = this.request(userId, id); + if (row.recipientUserId !== userId || row.status !== 'pending') + fail(409, 'REQUEST_STATE', '거절할 수 없는 요청입니다.'); + this.service.db + .update(s.requests) + .set({ status: 'rejected', rejectedAt: Date.now() }) + .where(eq(s.requests.id, id)) + .run(); + } + cancel(userId: string, id: string) { + const row = this.request(userId, id), + batch = this.service.db.select().from(s.batches).where(eq(s.batches.requestId, id)).get()!; + if ( + row.senderUserId !== userId || + !['pending', 'accepted'].includes(row.status) || + batch.status === 'stored' + ) + fail(409, 'REQUEST_STATE', '취소할 수 없는 요청입니다.'); + this.service.storage.release(id); + this.service.db + .update(s.requests) + .set({ status: 'cancelled' }) + .where(eq(s.requests.id, id)) + .run(); + this.service.db + .update(s.batches) + .set({ status: 'failed' }) + .where(eq(s.batches.id, batch.id)) + .run(); + } + complete(userId: string, id: string, input: { urls: string[] }) { + const row = this.request(userId, id); + if ( + row.senderUserId !== userId || + !this.canTransfer(row) || + !row.startBy || + row.startBy <= Date.now() + ) + fail(409, 'REQUEST_STATE', '수락된 유효한 요청이 필요합니다.'); + if (row.kind === 'file') + fail(400, 'FILE_UPLOAD_REQUIRED', '파일은 업로드 API를 사용해 주세요.'); + const batch = this.service.db + .select() + .from(s.batches) + .where(eq(s.batches.requestId, id)) + .get()!; + if (batch.status !== 'waiting') fail(409, 'TRANSFER_STATE', '이미 처리된 전송입니다.'); + if (row.kind !== 'url') + fail(400, 'TYPE_UNSUPPORTED', '파일과 HTTP/HTTPS 링크만 보낼 수 있습니다.'); + const parsed = parseUrls(input.urls.join('\n')); + if (parsed.invalid.length || parsed.urls.length !== input.urls.length) + fail(400, 'INVALID_URL', 'HTTP 또는 HTTPS 링크만 보낼 수 있습니다.'); + const values = parsed.urls; + if (Buffer.byteLength(values.join('\n')) !== row.byteSize || values.length !== row.count) + fail(409, 'METADATA_MISMATCH', '내용 크기나 개수가 바뀌었습니다. 새 요청을 보내 주세요.'); + const now = Date.now(); + for (const value of values) { + const itemId = randomUUID(); + this.service.db + .insert(s.items) + .values({ + id: itemId, + batchId: batch.id, + kind: row.kind, + + url: value, + createdAt: now, + expiresAt: now + this.service.cfg.urlRetentionDays * 86400000, + }) + .run(); + this.createAccess(row, itemId); + } + this.service.db + .update(s.batches) + .set({ status: 'stored', storedAt: now }) + .where(eq(s.batches.id, batch.id)) + .run(); + return { requestId: id, batchId: batch.id }; + } + createAccess(row: RequestRow, itemId: string) { + if (row.mode === 'local') { + for (const [guestId, role] of [ + [row.senderGuestId!, 'sender'], + [row.recipientGuestId!, 'recipient'], + ] as const) + this.service.db + .insert(s.access) + .values({ id: randomUUID(), itemId, guestId, role, read: false }) + .run(); + this.service.vault.receipt(row, itemId); + } else if (row.senderUserId === row.recipientUserId) + this.service.db + .insert(s.access) + .values({ + id: randomUUID(), + itemId, + userId: row.senderUserId!, + role: 'self', + read: false, + note: row.note, + }) + .run(); + else + for (const [userId, role] of [ + [row.senderUserId!, 'sender'], + [row.recipientUserId!, 'recipient'], + ] as const) + this.service.db + .insert(s.access) + .values({ id: randomUUID(), itemId, userId, role, read: false }) + .run(); + } + listRequests(userId: string): EmailRequest[] { + this.verified(userId); + return this.service.db + .select({ request: s.requests, batch: s.batches }) + .from(s.requests) + .innerJoin(s.batches, eq(s.requests.id, s.batches.requestId)) + .where( + and( + eq(s.requests.mode, 'email'), + gt(s.requests.createdAt, Date.now() - 7 * 86400000), + inArray(s.requests.kind, ['url', 'file']), + or(eq(s.requests.senderUserId, userId), eq(s.requests.recipientUserId, userId)), + ), + ) + .orderBy(desc(s.requests.createdAt), desc(s.requests.id)) + .limit(100) + .all() + .map(({ request: r, batch: b }) => ({ + id: r.id, + batchId: b.id, + kind: r.kind as 'url' | 'file', + byteSize: r.byteSize, + count: r.count, + status: r.status, + transferStatus: b.status, + createdAt: r.createdAt, + expiresAt: r.expiresAt, + startBy: r.startBy, + direction: r.senderUserId === userId ? 'sent' : 'received', + sender: this.service.auth.serialize(this.service.auth.user(r.senderUserId!)), + recipient: this.service.auth.serialize(this.service.auth.user(r.recipientUserId!)), + files: + r.kind === 'file' + ? this.service.storage.fileRows(r.id).map((f) => ({ + id: f.id, + name: f.name, + size: f.size, + type: f.type, + status: f.status, + })) + : undefined, + })); + } + access(userId: string, id: string) { + this.verified(userId); + const row = this.service.db + .select({ access: s.access, item: s.items }) + .from(s.access) + .innerJoin(s.items, eq(s.access.itemId, s.items.id)) + .where( + and( + eq(s.access.id, id), + eq(s.access.userId, userId), + inArray(s.items.kind, ['url', 'file']), + isNull(s.access.deletedAt), + gt(s.items.expiresAt, Date.now()), + ), + ) + .get(); + if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.'); + return row!; + } + read(userId: string, id: string, value: boolean) { + const row = this.access(userId, id); + this.service.db + .update(s.access) + .set({ read: value }) + .where(and(eq(s.access.itemId, row.item.id), eq(s.access.role, row.access.role))) + .run(); + for (const access of this.service.db + .select() + .from(s.access) + .where(eq(s.access.itemId, row.item.id)) + .all()) { + if (access.userId) this.notify(access.userId); + if (access.guestId) this.service.presence.emit(access.guestId, 'access:updated', {}); + } + } + list( + userId: string, + view: 'inbox' | 'vault' | 'sent' | 'favorites' | 'contents', + query: { cursor?: string; limit: number; search?: string; kind?: string; read?: boolean }, + ) { + this.verified(userId); + let after: { createdAt: number; id: string } | undefined; + if (query.cursor) { + try { + after = JSON.parse(Buffer.from(query.cursor, 'base64url').toString()); + if (!after || !Number.isSafeInteger(after.createdAt) || typeof after.id !== 'string') + throw Error(); + } catch { + fail(400, 'INVALID_CURSOR', '목록 커서가 올바르지 않습니다.'); + } + } + const rows = this.service.db + .select({ access: s.access, item: s.items, request: s.requests }) + .from(s.access) + .innerJoin(s.items, eq(s.access.itemId, s.items.id)) + .innerJoin(s.batches, eq(s.items.batchId, s.batches.id)) + .innerJoin(s.requests, eq(s.batches.requestId, s.requests.id)) + .where( + and( + eq(s.access.userId, userId), + isNull(s.access.deletedAt), + gt(s.items.expiresAt, Date.now()), + view === 'contents' + ? inArray(s.access.role, ['self', 'recipient']) + : view === 'favorites' + ? eq(s.access.favorite, true) + : eq( + s.access.role, + view === 'inbox' ? 'recipient' : view === 'vault' ? 'self' : 'sender', + ), + inArray(s.items.kind, ['url', 'file']), + query.kind ? eq(s.items.kind, query.kind as any) : undefined, + query.read !== undefined ? eq(s.access.read, query.read) : undefined, + query.search + ? sql`(instr(lower(coalesce(${s.access.note},'')),lower(${query.search})) > 0 OR instr(lower(coalesce(${s.items.url},'')),lower(${query.search})) > 0 OR EXISTS (SELECT 1 FROM files f WHERE f.id = ${s.items.fileId} AND instr(lower(f.name),lower(${query.search})) > 0))` + : undefined, + after + ? or( + lt(s.items.createdAt, after.createdAt), + and(eq(s.items.createdAt, after.createdAt), lt(s.access.id, after.id)), + ) + : undefined, + ), + ) + .orderBy(desc(s.items.createdAt), desc(s.access.id)) + .limit(query.limit + 1) + .all(); + const result: AccountItem[] = rows.slice(0, query.limit).map((r) => { + const peerId = + r.access.role === 'sender' ? r.request.recipientUserId : r.request.senderUserId; + const peer = peerId ? this.service.auth.user(peerId) : null; + const file = r.item.fileId + ? this.service.db.select().from(s.files).where(eq(s.files.id, r.item.fileId)).get() + : null; + const recipient = this.service.db + .select() + .from(s.access) + .where(and(eq(s.access.itemId, r.item.id), eq(s.access.role, 'recipient'))) + .get(); + return { + accessId: r.access.id, + id: r.item.id, + batchId: r.item.batchId, + kind: r.item.kind as 'url' | 'file', + url: r.item.url, + role: r.access.role, + read: r.access.read, + favorite: r.access.favorite, + note: r.access.note, + file: file ? { id: file.id, name: file.name, size: file.size, type: file.type } : undefined, + peerName: + peer?.displayName ?? + (r.request.senderGuestId + ? this.service.device(r.request.senderGuestId).name + : '익명 기기'), + peerCode: '', + senderEmail: peer?.email ?? undefined, + senderName: peer?.displayName, + createdAt: r.item.createdAt, + expiresAt: r.item.expiresAt, + recipientRead: recipient?.read ?? false, + }; + }); + const last = result.at(-1); + return { + items: result, + nextCursor: + rows.length > query.limit && last + ? Buffer.from(JSON.stringify({ createdAt: last.createdAt, id: last.accessId })).toString( + 'base64url', + ) + : null, + }; + } + used(userId: string) { + const db = this.service.db; + const owned = db + .select() + .from(s.files) + .where(and(eq(s.files.ownerUserId, userId), inArray(s.files.status, ['stored', 'cleaning']))) + .all(); + const accounted = new Map(owned.map((f) => [f.id, f.size])); + const preserved = db + .select({ file: s.files }) + .from(s.access) + .innerJoin(s.items, eq(s.access.itemId, s.items.id)) + .innerJoin(s.files, eq(s.items.fileId, s.files.id)) + .where( + and( + eq(s.access.userId, userId), + or(eq(s.access.favorite, true), eq(s.access.role, 'self')), + isNull(s.access.deletedAt), + gt(s.items.expiresAt, Date.now()), + ), + ) + .all(); + for (const { file } of preserved) accounted.set(file.id, file.size); + return [...accounted.values()].reduce((a, b) => a + b, 0); + } + ensureQuota(userId: string, bytes: number) { + if (this.used(userId) + bytes > this.service.cfg.userQuota) + fail(413, 'ACCOUNT_QUOTA', '계정 저장 용량을 초과했습니다.'); + } + favorite(userId: string, id: string, value: boolean) { + this.service.db.transaction(() => { + const row = this.access(userId, id); + if (value && !row.access.favorite && row.item.fileId) { + const file = this.service.db + .select() + .from(s.files) + .where(eq(s.files.id, row.item.fileId)) + .get()!; + if (file.ownerUserId !== userId && row.access.role !== 'self') + this.ensureQuota(userId, file.size); + } + this.service.db.update(s.access).set({ favorite: value }).where(eq(s.access.id, id)).run(); + }); + this.notify(userId); + } + move(g: Guest, userId: string, id: string) { + this.verified(userId); + return this.service.db.transaction(() => { + const row = this.service.accessDetail(g, id); + if (row.item.fileId) { + const file = this.service.db + .select() + .from(s.files) + .where(eq(s.files.id, row.item.fileId)) + .get()!; + this.ensureQuota(userId, file.size); + if (file.ownerGuestId === g.id) + this.service.db + .update(s.files) + .set({ ownerGuestId: null, ownerUserId: userId }) + .where(eq(s.files.id, file.id)) + .run(); + } + const existing = this.service.db + .select() + .from(s.access) + .where( + and( + eq(s.access.itemId, row.item.id), + eq(s.access.userId, userId), + eq(s.access.role, 'self'), + ), + ) + .get(); + if (existing) + this.service.db + .update(s.access) + .set({ deletedAt: null }) + .where(eq(s.access.id, existing.id)) + .run(); + else + this.service.db + .insert(s.access) + .values({ + id: randomUUID(), + itemId: row.item.id, + userId, + role: 'self', + read: row.access.read, + }) + .run(); + this.service.db + .update(s.access) + .set({ deletedAt: Date.now() }) + .where(eq(s.access.id, row.access.id)) + .run(); + return { ok: true }; + }); + } + rule(userId: string, target: string, action: 'allow' | 'block') { + this.verified(userId); + if (target === userId) + fail(400, 'RULE_INVALID', '자기 계정에는 수신 규칙을 적용하지 않습니다.'); + const candidate = this.service.db + .select() + .from(s.requests) + .where(and(eq(s.requests.senderUserId, target), eq(s.requests.recipientUserId, userId))) + .get(); + const existing = this.service.db + .select() + .from(s.rules) + .where(and(eq(s.rules.userId, userId), eq(s.rules.targetUserId, target))) + .get(); + if (!candidate && !existing) + fail(404, 'SENDER_NOT_FOUND', '수신 관계가 있는 발신자만 관리할 수 있습니다.'); + this.service.db + .insert(s.rules) + .values({ id: randomUUID(), userId, targetUserId: target, action }) + .onConflictDoUpdate({ target: [s.rules.userId, s.rules.targetUserId], set: { action } }) + .run(); + if (action === 'block') { + const affected = this.service.db + .select({ request: s.requests, batch: s.batches }) + .from(s.requests) + .innerJoin(s.batches, eq(s.requests.id, s.batches.requestId)) + .where( + and( + eq(s.requests.senderUserId, target), + eq(s.requests.recipientUserId, userId), + inArray(s.requests.status, ['pending', 'accepted']), + inArray(s.batches.status, ['waiting', 'uploading']), + ), + ) + .all(); + for (const { request } of affected) { + this.service.storage.release(request.id); + this.service.db + .update(s.requests) + .set({ + status: request.status === 'pending' ? 'rejected' : 'cancelled', + rejectedAt: Date.now(), + }) + .where(eq(s.requests.id, request.id)) + .run(); + } + this.notify(target); + } + this.notify(userId); + } +} diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts new file mode 100644 index 0000000..d0fb8ca --- /dev/null +++ b/apps/server/src/app.ts @@ -0,0 +1,828 @@ +import Fastify, { LogController, type FastifyRequest } from 'fastify'; +class PrivateLogs extends LogController { + constructor() { + super({ disableRequestLogging: true }); + } +} +import cookie from '@fastify/cookie'; +import multipart from '@fastify/multipart'; +import { createReadStream } from 'node:fs'; +import serveStatic from '@fastify/static'; +import { Server } from 'socket.io'; +import { timingSafeEqual } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { z } from 'zod'; +import { + devicePatch, + requestInput, + spaceInput, + joinInput, + completeInput, + emailRequestInput, +} from '@hop/shared'; +import { createResendMailer } from './mail.js'; +import { Auth, normalizeEmail, type Mailer } from './auth.js'; +import { Accounts } from './accounts.js'; +import * as tables from './db/schema.js'; +import { eq, and, inArray } from 'drizzle-orm'; +import type { StorageSubject } from './storage.js'; +import type { Config } from './config.js'; +import { openDatabase } from './db/index.js'; +import { Service, type Guest } from './service.js'; +import { Presence } from './presence.js'; +import { ApiError, fail, clientIp } from './security.js'; + +export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) { + const app = Fastify({ + logger: cfg.production + ? { + level: 'info', + redact: ['req.headers.cookie', 'req.headers.authorization', 'req.url'], + serializers: { req: (r) => ({ method: r.method }) }, + } + : false, + logController: new PrivateLogs(), + trustProxy: false, + bodyLimit: cfg.maxUrlBytes * 6 + 16384, + }); + await app.register(cookie); + await app.register(multipart, { + limits: { fileSize: cfg.maxFileBytes + 1, files: 1, fields: 0, parts: 1 }, + }); + const { db, sqlite } = openDatabase(cfg.databasePath), + service = new Service(db, cfg); + let devOrigin: string | undefined; + const allowed = (origin: string | undefined) => + Boolean( + origin && + (origin === new URL(cfg.appUrl).origin || (!cfg.production && origin === devOrigin)), + ); + const ip = (req: FastifyRequest) => + clientIp(req.raw.socket.remoteAddress, req.headers, cfg.trustedProxies); + const io = new Server(app.server, { + serveClient: false, + maxHttpBufferSize: 16384, + allowRequest: (req, callback) => { + // Browsers omit Origin for same-origin polling GETs. Fetch Metadata is browser-owned; + // cookie authentication and the session-bound CSRF token are also required by io.use. + const sameOriginFetch = + !req.headers.origin && req.headers['sec-fetch-site'] === 'same-origin'; + callback(null, allowed(req.headers.origin) || sameOriginFetch); + }, + }); + const presence = new Presence(io, cfg, () => service.refresh()); + service.presence = presence; + service.io = io; + service.auth = new Auth(service, options.mailer ?? createResendMailer(cfg)); + service.accounts = new Accounts(service); + io.use((socket, next) => { + try { + const token = app.parseCookie(socket.request.headers.cookie ?? '').hop_guest; + const guest = service.guest(token); + if (!guest || socket.handshake.auth.csrfToken !== service.csrf(token!)) + return next(new Error('SESSION_EXPIRED')); + socket.data.guest = guest; + const account = service.auth.session( + app.parseCookie(socket.request.headers.cookie ?? '').hop_account, + ); + if (account) { + socket.data.accountId = account.user.id; + socket.data.accountSessionId = account.session.id; + } + socket.data.ip = clientIp( + socket.request.socket.remoteAddress, + socket.request.headers, + cfg.trustedProxies, + ); + next(); + } catch { + next(new Error('SESSION_EXPIRED')); + } + }); + io.on('connection', (socket) => { + presence.connect((socket.data.guest as Guest).id, socket, socket.data.ip); + if (socket.data.accountId) { + socket.join(`account:${socket.data.accountId}`); + socket.join(`account-session:${socket.data.accountSessionId}`); + } + }); + const auth = (req: FastifyRequest) => { + const guest = service.guest(req.cookies.hop_guest); + if (!guest) fail(401, 'SESSION_EXPIRED', '기기 세션이 만료되었습니다. 새로 접속해 주세요.'); + return guest!; + }; + const account = (req: FastifyRequest, verified = false) => + service.auth.require(req.cookies.hop_account, verified); + const requestSubject = (req: FastifyRequest, id: string): StorageSubject => { + const row = db.select().from(tables.requests).where(eq(tables.requests.id, id)).get(); + const user = service.auth.session(req.cookies.hop_account)?.user; + if ( + row?.mode === 'email' && + user && + (row.senderUserId === user.id || row.recipientUserId === user.id) + ) { + account(req, true); + return { id: user.id, kind: 'user', sessionId: account(req, true).session.id }; + } + return auth(req); + }; + const accessSubject = (req: FastifyRequest, id: string): StorageSubject => { + const row = db.select().from(tables.access).where(eq(tables.access.id, id)).get(), + user = service.auth.session(req.cookies.hop_account)?.user; + if (user && row?.userId === user.id) { + account(req, true); + return { id: user.id, kind: 'user', sessionId: account(req, true).session.id }; + } + return auth(req); + }; + const readAccess = (subject: StorageSubject, id: string, value: boolean) => { + if (subject.kind === 'user') service.accounts.read(subject.id, id, value); + else service.read(subject as Guest, id, value); + }; + const csrf = (req: FastifyRequest) => { + const token = req.cookies.hop_guest; + const actual = req.headers['x-csrf-token']; + if (!token || typeof actual !== 'string') + fail(403, 'CSRF_INVALID', '요청을 확인할 수 없습니다. 새로고침해 주세요.'); + const expected = Buffer.from(service.csrf(token!)); + const given = Buffer.from(actual as string); + if (given.length !== expected.length || !timingSafeEqual(given, expected)) + fail(403, 'CSRF_INVALID', '요청을 확인할 수 없습니다.'); + }; + const key = (req: FastifyRequest) => + typeof req.headers['idempotency-key'] === 'string' ? req.headers['idempotency-key'] : undefined; + const id = (req: FastifyRequest) => z.object({ id: z.uuid() }).parse(req.params).id; + app.addHook('onRequest', async (req, reply) => { + reply + .header('X-Content-Type-Options', 'nosniff') + .header('Referrer-Policy', 'no-referrer') + .header('X-Frame-Options', 'DENY') + .header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), bluetooth=()'); + reply.header( + 'Content-Security-Policy', + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'", + ); + if (req.url.startsWith('/api/')) { + reply.header('Cache-Control', 'no-store'); + service.limiter.hit(`api-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, 240); + if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { + if (!allowed(req.headers.origin)) + fail(403, 'ORIGIN_INVALID', '허용되지 않은 접속 경로입니다.'); + if (req.url.split('?')[0] !== '/api/guest/session') { + auth(req); + csrf(req); + } + } + } + }); + app.setErrorHandler((err, req, reply) => { + let status = 500, + code = 'INTERNAL_ERROR', + message = '처리 중 오류가 발생했습니다.'; + if (err instanceof ApiError) { + status = err.status; + code = err.code; + message = err.message; + } else if (err instanceof z.ZodError) { + status = 400; + code = 'VALIDATION_ERROR'; + message = '입력 내용을 확인해 주세요.'; + } else if ( + err && + typeof err === 'object' && + 'statusCode' in err && + typeof err.statusCode === 'number' && + err.statusCode < 500 + ) { + status = err.statusCode; + code = 'INVALID_REQUEST'; + message = '요청 형식이나 크기를 확인해 주세요.'; + } + reply.status(status).send({ code, message, requestId: req.id }); + }); + const setCookie = (reply: any, token: string, expiresAt: number) => + reply.setCookie('hop_guest', token, { + httpOnly: true, + secure: cfg.production, + sameSite: 'lax', + path: '/', + expires: new Date(expiresAt), + }); + app.get('/api/health', async () => ({ status: 'ok', phase: 1 })); + app.post('/api/guest/session', async (req, reply) => { + service.limiter.hit(`session-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, 20); + let token = req.cookies.hop_guest, + guest = service.guest(token); + if (!guest) { + const created = service.createGuest(req.headers['user-agent'] ?? ''); + token = created.token; + guest = created.guest; + } + setCookie(reply, token!, guest.expiresAt); + return { + device: service.device(guest.id), + csrfToken: service.csrf(token!), + expiresAt: guest.expiresAt, + }; + }); + app.get('/api/device/snapshot', async (req) => { + const g = auth(req); + return db.transaction(() => { + const r = service.listRequests(g), + list = service.listItems(g); + return { + device: service.device(g.id), + devices: service.peers(g), + sameNetworkDiscoveryEnabled: cfg.discoveryEnabled, + requests: r, + items: list.items, + nextCursor: list.nextCursor, + spaces: service.listSpaces(g), + }; + }); + }); + app.get('/api/device/me', async (req) => ({ + device: service.device(auth(req).id), + sameNetworkDiscoveryEnabled: cfg.discoveryEnabled, + })); + app.patch('/api/device/me', async (req) => { + const guest = auth(req); + service.patchDevice(guest, devicePatch.parse(req.body)); + return { device: service.device(guest.id) }; + }); + app.post('/api/device/reset', async (req, reply) => { + const guest = auth(req); + service.reset(guest); + const created = service.createGuest(req.headers['user-agent'] ?? ''); + setCookie(reply, created.token, created.guest.expiresAt); + return { + device: service.device(created.guest.id), + csrfToken: service.csrf(created.token), + expiresAt: created.guest.expiresAt, + }; + }); + app.get('/api/discovery/devices', async (req) => ({ + devices: service.peers(auth(req)), + sameNetworkDiscoveryEnabled: cfg.discoveryEnabled, + })); + app.get('/api/spaces', async (req) => ({ spaces: service.listSpaces(auth(req)) })); + app.post('/api/spaces', async (req) => + service.createSpace(auth(req), spaceInput.parse(req.body).approvalRequired), + ); + app.post('/api/spaces/join', async (req) => + service.joinSpace(auth(req), joinInput.parse(req.body), ip(req) ?? 'unknown'), + ); + app.post('/api/spaces/:id/members/:memberId/approve', async (req) => { + const p = z.object({ id: z.uuid(), memberId: z.uuid() }).parse(req.params); + service.approve(auth(req), p.id, p.memberId); + return { ok: true }; + }); + app.delete('/api/spaces/:id/members/:memberId', async (req) => { + const p = z.object({ id: z.uuid(), memberId: z.uuid() }).parse(req.params); + service.remove(auth(req), p.id, p.memberId); + return { ok: true }; + }); + app.delete('/api/spaces/:id', async (req) => { + service.closeSpace(auth(req), id(req)); + return { ok: true }; + }); + app.get('/api/transfer-requests', async (req) => { + const mode = (req.query as { mode?: string }).mode; + return { + requests: + mode === 'email' + ? service.accounts.listRequests(account(req, true).user.id) + : service.listRequests(auth(req)), + }; + }); + app.post('/api/transfer-requests', async (req) => { + if ((req.body as { mode?: string })?.mode === 'email') { + const user = account(req, true).user, + input = emailRequestInput.parse(req.body); + input.email = normalizeEmail(input.email); + if (input.email !== user.email) + fail(410, 'EMAIL_TRANSFER_REMOVED', '이메일 전송 대신 내 임시 보관함을 이용해 주세요.'); + const result = service.accounts.idem(user.id, 'request-create', key(req), input, () => + service.accounts.create(user.id, input), + ); + service.notifyTransfer(result.requestId); + return result; + } + const g = auth(req), + input = requestInput.parse(req.body); + const result = service.idem(g, 'request-create', key(req), input, () => + service.createRequest(g, input, ip(req) ?? 'unknown'), + ); + service.notifyTransfer(result.requestId); + return result; + }); + app.post('/api/transfer-requests/:id/accept', async (req) => { + const requestId = id(req), + subject = requestSubject(req, requestId); + let result; + if (subject.kind === 'user') { + const input = z + .object({ allowSender: z.boolean().default(false) }) + .strict() + .parse(req.body); + result = service.accounts.idem(subject.id, `accept:${requestId}`, key(req), input, () => + service.accounts.accept(subject.id, requestId, input.allowSender), + ); + } else { + service.request(subject as Guest, requestId); + result = service.idem(subject as Guest, `accept:${requestId}`, key(req), {}, () => + (() => { + const result = service.accept(subject as Guest, requestId); + service.vault.bindReceipt(requestId, service.auth.session(req.cookies.hop_account)); + service.vault.deliver(requestId); + return result; + })(), + ); + } + service.notifyTransfer(requestId); + return result; + }); + app.post('/api/transfer-requests/:id/reject', async (req) => { + const requestId = id(req), + subject = requestSubject(req, requestId); + if (subject.kind === 'user') service.accounts.reject(subject.id, requestId); + else service.reject(subject as Guest, requestId); + service.notifyTransfer(requestId); + return { ok: true }; + }); + app.post('/api/transfer-requests/:id/cancel', async (req) => { + const requestId = id(req), + subject = requestSubject(req, requestId); + if (subject.kind === 'user') service.accounts.cancel(subject.id, requestId); + else service.cancel(subject as Guest, requestId); + service.notifyTransfer(requestId); + return { ok: true }; + }); + app.post('/api/transfer-requests/:id/block', async (req) => { + const requestId = id(req), + subject = requestSubject(req, requestId); + if (subject.kind === 'user') { + const row = service.accounts.request(subject.id, requestId); + if (row.recipientUserId !== subject.id) + fail(403, 'FORBIDDEN', '수신 계정만 차단할 수 있습니다.'); + service.accounts.rule(subject.id, row.senderUserId!, 'block'); + } else service.block(subject as Guest, requestId); + return { ok: true }; + }); + app.post('/api/transfers/:id/complete', async (req) => { + const requestId = id(req), + subject = requestSubject(req, requestId), + input = completeInput.parse(req.body); + if (service.vault.source(requestId)) + fail(409, 'VAULT_TRANSFER_MANAGED', '보관함 전송은 서버에서 처리합니다.'); + let result; + if (subject.kind === 'user') + result = service.accounts.idem(subject.id, `complete:${requestId}`, key(req), input, () => + service.accounts.complete(subject.id, requestId, input), + ); + else { + service.request(subject as Guest, requestId); + result = service.idem(subject as Guest, `complete:${requestId}`, key(req), input, () => + service.complete(subject as Guest, requestId, input), + ); + } + service.notifyTransfer(requestId); + return result; + }); + app.get('/api/device/items', async (req) => { + const q = z + .object({ + cursor: z.string().max(512).optional(), + limit: z.coerce.number().int().min(1).max(100).default(30), + }) + .parse(req.query); + return service.listItems(auth(req), q.cursor, q.limit); + }); + app.get('/api/access/:id', async (req) => ({ + item: + accessSubject(req, id(req)).kind === 'user' + ? service.accounts.access(account(req, true).user.id, id(req)).item + : service.accessDetail(auth(req), id(req)).item, + })); + app.patch('/api/access/:id/read', async (req) => { + const body = z.object({ read: z.boolean() }).strict().parse(req.body); + readAccess(accessSubject(req, id(req)), id(req), body.read); + return { ok: true }; + }); + app.post('/api/uploads', async (req) => { + const input = z + .object({ + requestId: z.uuid(), + fileIndex: z.coerce + .number() + .int() + .min(0) + .max(cfg.maxItems - 1), + }) + .parse(req.query); + const uploadKey = z + .string() + .regex(/^[A-Za-z0-9_-]{8,128}$/) + .parse(key(req)); + if (service.vault.source(input.requestId)) + fail(409, 'VAULT_TRANSFER_MANAGED', '이미 저장된 파일을 전달합니다.'); + return service.storage.upload( + requestSubject(req, input.requestId), + input.requestId, + input.fileIndex, + req, + uploadKey, + ); + }); + app.delete('/api/uploads/:id', async (req) => { + service.storage.cancel( + (() => { + const file = db + .select() + .from(tables.files) + .where(eq(tables.files.uploadId, id(req))) + .get(); + const user = service.auth.session(req.cookies.hop_account)?.user; + return user && file?.ownerUserId === user.id + ? { id: user.id, kind: 'user' as const } + : auth(req); + })(), + id(req), + ); + return { ok: true }; + }); + app.get('/api/access/:id/download', async (req, reply) => { + const accessId = id(req), + g = accessSubject(req, accessId); + const { file, path } = service.storage.download(g, accessId); + const fallback = + file.name.replace(/[^a-zA-Z0-9._ -]/g, '_').replace(/^[. ]+/, '') || 'download'; + const encoded = encodeURIComponent(file.name).replace( + /['()*]/g, + (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase(), + ); + reply + .header( + 'Content-Disposition', + `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`, + ) + .header('Content-Length', file.size) + .type('application/octet-stream'); + readAccess(g, accessId, true); + return reply.send(createReadStream(path)); + }); + app.get('/api/access/:id/preview', async (req, reply) => + reply + .type('image/webp') + .send(await service.storage.preview(accessSubject(req, id(req)), id(req))), + ); + app.post('/api/access/delete-batch', async (req) => { + const body = z + .object({ ids: z.array(z.uuid()).min(1).max(100) }) + .strict() + .parse(req.body), + g = accessSubject(req, body.ids[0]!); + const result = service.storage.deleteAccess(g, body.ids); + if (g.kind === 'user') service.accounts.notify(g.id); + else presence.emit(g.id, 'access:updated', {}); + return result; + }); + app.post('/api/deletion-operations/:id/undo', async (req) => { + const operation = db + .select() + .from(tables.deletions) + .where(eq(tables.deletions.id, id(req))) + .get(); + const user = service.auth.session(req.cookies.hop_account)?.user; + const g: StorageSubject = + user && operation?.userId === user.id ? { id: user.id, kind: 'user' } : auth(req); + const result = service.storage.undo(g, id(req)); + if (g.kind === 'user') service.accounts.notify(g.id); + else presence.emit(g.id, 'access:updated', {}); + return result; + }); + const credentials = z + .object({ email: z.string().max(254), password: z.string().min(1).max(1024) }) + .strict(); + const registration = credentials + .extend({ + passwordConfirmation: z.string().max(1024), + displayName: z + .string() + .trim() + .min(1) + .max(40) + .refine((v) => !/[\x00-\x1f\x7f<>]/.test(v)), + setupToken: z.string().min(32).max(256).optional(), + }) + .refine((v) => v.password === v.passwordConfirmation); + const tokenInput = z.object({ token: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).strict(); + const clearAccount = (reply: any) => + reply.clearCookie('hop_account', { + path: '/', + httpOnly: true, + secure: cfg.production, + sameSite: 'lax', + }); + app.get('/api/auth/me', async (req) => { + const current = service.auth.session(req.cookies.hop_account); + return { + account: current ? service.auth.serialize(current.user) : null, + registrationOpen: cfg.registrationMode === 'open', + passwordMinLength: cfg.production ? 12 : 1, + }; + }); + app.post('/api/auth/register', async (req) => { + const input = registration.parse(req.body); + if (input.setupToken) fail(400, 'INVALID_REQUEST', '초기화는 별도 경로를 사용해 주세요.'); + return service.auth.register( + input.email, + input.password, + input.displayName, + ip(req) ?? 'unknown', + ); + }); + app.post('/api/auth/setup', async (req) => { + const input = registration.parse(req.body); + if (!input.setupToken) fail(403, 'SETUP_DISABLED', '초기화 토큰이 필요합니다.'); + return service.auth.register( + input.email, + input.password, + input.displayName, + ip(req) ?? 'unknown', + input.setupToken, + ); + }); + app.post('/api/auth/verify-email', async (req) => + service.auth.verify(tokenInput.parse(req.body).token), + ); + app.post('/api/auth/verify-email-change', async (req) => + service.auth.verifyEmailChange(tokenInput.parse(req.body).token), + ); + app.post('/api/auth/resend-verification', async (req) => + service.auth.resend( + z + .object({ email: z.string().max(254) }) + .strict() + .parse(req.body).email, + 'verify', + ip(req) ?? 'unknown', + ), + ); + app.post('/api/auth/login', async (req, reply) => { + const input = credentials.parse(req.body); + const result = await service.auth.login( + input.email, + input.password, + ip(req) ?? 'unknown', + req.cookies.hop_account, + ); + reply.setCookie('hop_account', result.token, { + httpOnly: true, + secure: cfg.production, + sameSite: 'lax', + path: '/', + expires: new Date(result.expiresAt), + }); + return { account: result.account }; + }); + app.post('/api/auth/logout', async (req, reply) => { + service.auth.logout(req.cookies.hop_account); + clearAccount(reply); + return { ok: true }; + }); + app.post('/api/auth/forgot-password', async (req) => + service.auth.resend( + z + .object({ email: z.string().max(254) }) + .strict() + .parse(req.body).email, + 'reset', + ip(req) ?? 'unknown', + ), + ); + app.post('/api/auth/reset-password', async (req) => { + const input = tokenInput + .extend({ password: z.string().max(1024), passwordConfirmation: z.string().max(1024) }) + .refine((v) => v.password === v.passwordConfirmation) + .parse(req.body); + return service.auth.resetPassword(input.token, input.password); + }); + app.post('/api/auth/change-password', async (req, reply) => { + const input = z + .object({ + currentPassword: z.string().max(1024), + password: z.string().max(1024), + passwordConfirmation: z.string().max(1024), + }) + .strict() + .refine((v) => v.password === v.passwordConfirmation) + .parse(req.body); + await service.auth.changePassword(account(req).user, input.currentPassword, input.password); + clearAccount(reply); + return { ok: true }; + }); + app.post('/api/auth/change-email', async (req) => { + const input = z + .object({ email: z.string().max(254), password: z.string().max(1024) }) + .strict() + .parse(req.body); + await service.auth.changeEmail(account(req).user, input.email, input.password); + return { ok: true }; + }); + for (const view of ['inbox', 'vault', 'sent', 'favorites'] as const) + app.get(`/api/${view}`, async (req) => { + const input = z + .object({ + cursor: z.string().max(512).optional(), + limit: z.coerce.number().int().min(1).max(100).default(30), + search: z.string().max(256).optional(), + kind: z.enum(['url', 'file']).optional(), + read: z.enum(['true', 'false']).optional(), + }) + .parse(req.query); + return service.accounts.list(account(req, true).user.id, view, { + ...input, + read: input.read === undefined ? undefined : input.read === 'true', + }); + }); + app.get('/api/vault/contents', async (req) => { + const input = z + .object({ + cursor: z.string().max(512).optional(), + limit: z.coerce.number().int().min(1).max(100).default(30), + }) + .parse(req.query); + return service.accounts.list(account(req, true).user.id, 'contents', input); + }); + const noteInput = z + .string() + .trim() + .max(200) + .refine((value) => !/[\r\n]/.test(value)) + .default(''); + app.post('/api/vault', async (req) => { + const user = account(req, true).user; + const input = z + .discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('url'), + urls: z.array(z.string().max(cfg.maxUrlBytes)).min(1).max(cfg.maxItems), + note: noteInput, + }) + .strict(), + z + .object({ + kind: z.literal('file'), + files: requestInput.shape.files.unwrap().min(1).max(cfg.maxItems), + note: noteInput, + }) + .strict(), + ]) + .parse(req.body); + const result = service.accounts.idem(user.id, 'vault-create', key(req), input, () => { + const created = service.accounts.create(user.id, { + email: user.email!, + kind: input.kind, + count: input.kind === 'url' ? input.urls.length : input.files.length, + byteSize: + input.kind === 'url' + ? Buffer.byteLength(input.urls.join('\n')) + : input.files.reduce((sum, file) => sum + file.size, 0), + ...(input.kind === 'file' ? { files: input.files } : {}), + }); + db.update(tables.requests) + .set({ note: input.note || null }) + .where(eq(tables.requests.id, created.requestId)) + .run(); + if (input.kind === 'url') + service.accounts.complete(user.id, created.requestId, { urls: input.urls }); + return created; + }); + service.notifyTransfer(result.requestId); + return result; + }); + app.patch('/api/vault/:id/note', async (req) => { + const input = z.object({ note: noteInput }).strict().parse(req.body); + service.vault.note(account(req, true).user.id, id(req), input.note); + return { ok: true }; + }); + app.post('/api/vault/:id/send', async (req) => { + const guest = auth(req), + current = account(req, true), + accessId = id(req); + const input = z + .object({ targetDeviceId: requestInput.shape.targetDeviceId }) + .strict() + .parse(req.body); + const result = service.accounts.idem( + current.user.id, + `vault-send:${accessId}`, + key(req), + { ...input, guestId: guest.id, sessionId: current.session.id }, + () => + service.vault.forward(guest, current, accessId, input.targetDeviceId, ip(req) ?? 'unknown'), + ); + service.notifyTransfer(result.requestId); + return result; + }); + app.patch('/api/access/:id/favorite', async (req) => { + account(req, true); + fail(410, 'PERMANENT_RETENTION_REMOVED', '모든 자료는 최대 7일간 보관됩니다.'); + }); + app.post('/api/access/:id/save-to-account', async (req) => { + account(req, true); + fail(410, 'GUEST_IMPORT_REMOVED', '로그인 중 받은 자료만 자동 기록합니다.'); + }); + + app.get('/api/account/preferences', async (req) => ({ + receivePolicy: account(req, true).user.receivePolicy, + })); + app.patch('/api/account/preferences', async (req) => { + const input = z + .object({ receivePolicy: z.enum(['requests', 'allowed', 'none']) }) + .strict() + .parse(req.body), + user = account(req, true).user; + db.update(tables.users).set(input).where(eq(tables.users.id, user.id)).run(); + service.accounts.notify(user.id); + return { ok: true }; + }); + app.get('/api/account/rules', async (req) => ({ + rules: db + .select({ + id: tables.rules.targetUserId, + action: tables.rules.action, + email: tables.users.email, + displayName: tables.users.displayName, + }) + .from(tables.rules) + .innerJoin(tables.users, eq(tables.rules.targetUserId, tables.users.id)) + .where(eq(tables.rules.userId, account(req, true).user.id)) + .all(), + })); + app.put('/api/account/rules/:targetUserId', async (req) => { + const target = z.object({ targetUserId: z.uuid() }).parse(req.params).targetUserId, + input = z + .object({ action: z.enum(['allow', 'block']) }) + .strict() + .parse(req.body); + service.accounts.rule(account(req, true).user.id, target, input.action); + return { ok: true }; + }); + app.delete('/api/account/rules/:targetUserId', async (req) => { + const target = z.object({ targetUserId: z.uuid() }).parse(req.params).targetUserId, + user = account(req, true).user; + db.delete(tables.rules) + .where(and(eq(tables.rules.userId, user.id), eq(tables.rules.targetUserId, target))) + .run(); + service.accounts.notify(user.id); + return { ok: true }; + }); + app.get('/api/account/storage', async (req) => ({ + usedBytes: service.accounts.used(account(req, true).user.id), + limitBytes: cfg.userQuota, + })); + const webRoot = cfg.webRoot; + if (webRoot && existsSync(webRoot)) { + await app.register(serveStatic, { root: webRoot, index: 'index.html' }); + app.setNotFoundHandler((req, reply) => { + if (req.url.startsWith('/api/') || req.url.startsWith('/socket.io/')) + return reply + .status(404) + .send({ code: 'NOT_FOUND', message: '경로를 찾을 수 없습니다.', requestId: req.id }); + return reply.sendFile('index.html'); + }); + } + let storageCleanup: Promise | undefined; + const cleanup = setInterval(() => { + service.cleanup(); + service.auth.enforceSessions(); + storageCleanup = service.storage + .cleanup() + .catch(() => app.log.error({ code: 'CLEANUP_FAILED' }, 'Storage cleanup failed')); + }, 1000); + cleanup.unref(); + app.addHook('preClose', async () => { + service.storage.close(); + io.disconnectSockets(true); + }); + app.addHook('onClose', async () => { + clearInterval(cleanup); + presence.close(); + service.storage.close(); + await storageCleanup; + await new Promise((resolve) => io.close(() => resolve())); + sqlite.close(); + }); + return { + app, + io, + service, + sqlite, + setDevOrigin: (value: string) => { + devOrigin = value; + }, + }; +} diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts new file mode 100644 index 0000000..0abbb05 --- /dev/null +++ b/apps/server/src/auth.ts @@ -0,0 +1,374 @@ +import { randomUUID, timingSafeEqual } from 'node:crypto'; +import { and, eq, gt, isNull, inArray } from 'drizzle-orm'; +import argon2 from 'argon2'; +import type { Service } from './service.js'; +import type { Account } from '@hop/shared'; +import * as s from './db/schema.js'; +import { fail, hash, randomToken } from './security.js'; +export type Mail = { to: string; subject: string; text: string; idempotencyKey?: string }; +export type Mailer = (mail: Mail) => Promise; +export type User = typeof s.users.$inferSelect; +const passwordOptions = { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 1 }; +let dummy: Promise | undefined; +export function normalizeEmail(value: string) { + const trimmed = value.trim(); + const split = trimmed.lastIndexOf('@'); + if ( + split < 1 || + split === trimmed.length - 1 || + trimmed.length > 254 || + /[\s\x00-\x1f\x7f]/.test(trimmed) + ) + fail(400, 'EMAIL_INVALID', '이메일 주소를 확인해 주세요.'); + const local = trimmed.slice(0, split), + domain = trimmed.slice(split + 1).toLowerCase(); + if ( + !/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(local) || + !/^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,63}$/.test(domain) + ) + fail(400, 'EMAIL_INVALID', '이메일 주소를 확인해 주세요.'); + return `${local}@${domain}`; +} +export class Auth { + readonly sendMail: Mailer; + readonly mailReady: boolean; + constructor( + readonly service: Service, + mailer?: Mailer, + ) { + this.mailReady = Boolean(mailer); + this.sendMail = + mailer ?? + (async () => fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.')); + if (service.cfg.registrationMode === 'open' && !this.mailReady) + throw Error('Mail provider must be configured before opening registration'); + } + serialize(user: User): Account { + return { + id: user.id, + email: user.email ?? '', + displayName: user.status === 'deleted' ? '탈퇴한 사용자' : user.displayName, + role: user.role, + status: user.status, + verified: Boolean(user.verifiedAt && user.status === 'active'), + }; + } + user(id: string) { + const user = this.service.db.select().from(s.users).where(eq(s.users.id, id)).get(); + if (!user) fail(404, 'ACCOUNT_NOT_FOUND', '계정을 찾을 수 없습니다.'); + return user!; + } + session(token: string | undefined) { + if (!token) return undefined; + const row = this.service.db + .select({ session: s.accountSessions, user: s.users }) + .from(s.accountSessions) + .innerJoin(s.users, eq(s.accountSessions.userId, s.users.id)) + .where( + and( + eq(s.accountSessions.tokenHash, hash(token)), + isNull(s.accountSessions.revokedAt), + gt(s.accountSessions.expiresAt, Date.now()), + inArray(s.users.status, ['pending', 'active']), + ), + ) + .get(); + return row; + } + require(token: string | undefined, verified = false) { + const current = this.session(token); + if (!current) fail(401, 'LOGIN_REQUIRED', '로그인이 필요합니다.'); + if (verified && !this.serialize(current!.user).verified) + fail(403, 'VERIFICATION_REQUIRED', '이메일 인증을 완료해 주세요.'); + return current!; + } + async password(value: string) { + const minimum = this.service.cfg.production ? 12 : 1; + if (Array.from(value).length < minimum || value.length > 1024) + fail(400, 'PASSWORD_INVALID', `비밀번호는 ${minimum}~1024자로 입력해 주세요.`); + return argon2.hash(value, passwordOptions); + } + async token(user: User, purpose: 'verify' | 'reset' | 'email-change', value?: string) { + if (!this.mailReady) + fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.'); + const token = randomToken(), + now = Date.now(); + this.service.db.transaction(() => { + this.service.db + .update(s.accountTokens) + .set({ usedAt: now }) + .where( + and( + eq(s.accountTokens.userId, user.id), + eq(s.accountTokens.purpose, purpose), + isNull(s.accountTokens.usedAt), + ), + ) + .run(); + this.service.db + .insert(s.accountTokens) + .values({ + id: randomUUID(), + userId: user.id, + purpose, + tokenHash: hash(token), + value, + createdAt: now, + expiresAt: now + (purpose === 'verify' ? 24 : 1) * 3600000, + }) + .run(); + }); + const action = + purpose === 'verify' + ? 'verify' + : purpose === 'reset' + ? 'reset-password' + : 'verify-email-change'; + await this.sendMail({ + idempotencyKey: `auth-${purpose}-${hash(token)}`, + to: value ?? user.email!, + subject: purpose === 'reset' ? 'hop. 비밀번호 재설정' : 'hop. 이메일 인증', + text: `${purpose === 'reset' ? '비밀번호를 다시 설정하려면' : '이메일 인증을 완료하려면'} 다음 주소를 열어 주세요.\n${this.service.cfg.appUrl}/#${action}=${token}\n이 링크는 한 번만 사용할 수 있습니다.`, + }); + } + async register( + email: string, + password: string, + displayName: string, + ip: string, + setupToken?: string, + ) { + const { db, cfg, limiter } = this.service; + limiter.hit(`register-ip:${ip}`, 5, 3600000); + let admin = false; + if (setupToken) { + if ( + !cfg.setupToken || + hash(setupToken) !== hash(cfg.setupToken) || + db.select().from(s.users).limit(1).get() + ) + fail(403, 'SETUP_DISABLED', '초기화를 진행할 수 없습니다.'); + admin = true; + } else if (cfg.registrationMode !== 'open') + fail(403, 'REGISTRATION_CLOSED', '현재 공개 가입이 닫혀 있습니다.'); + if (!this.mailReady) + fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.'); + const normalized = normalizeEmail(email), + passwordHash = await this.password(password); + if (db.select().from(s.users).where(eq(s.users.email, normalized)).get()) + return { message: '가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.' }; + const user = db.transaction(() => { + if (admin && db.select().from(s.users).limit(1).get()) + fail(409, 'SETUP_DISABLED', '초기화가 이미 완료되었습니다.'); + const id = randomUUID(); + db.insert(s.users) + .values({ + id, + email: normalized, + passwordHash, + displayName, + role: admin ? 'admin' : 'user', + status: 'pending', + createdAt: Date.now(), + }) + .run(); + return this.user(id); + }); + await this.token(user, 'verify'); + return { message: '가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.' }; + } + consume( + token: string, + purpose: 'verify' | 'reset' | 'email-change', + fn: (token: typeof s.accountTokens.$inferSelect, user: User) => void, + ) { + return this.service.db.transaction(() => { + const row = this.service.db + .select() + .from(s.accountTokens) + .where( + and( + eq(s.accountTokens.tokenHash, hash(token)), + eq(s.accountTokens.purpose, purpose), + isNull(s.accountTokens.usedAt), + gt(s.accountTokens.expiresAt, Date.now()), + ), + ) + .get(); + if (!row) fail(400, 'TOKEN_INVALID', '링크가 유효하지 않거나 만료되었습니다.'); + const user = this.user(row!.userId); + if (['suspended', 'deleted'].includes(user.status)) + fail(400, 'TOKEN_INVALID', '링크가 유효하지 않거나 만료되었습니다.'); + fn(row!, user); + this.service.db + .update(s.accountTokens) + .set({ usedAt: Date.now() }) + .where(eq(s.accountTokens.id, row!.id)) + .run(); + return { ok: true }; + }); + } + verify(token: string) { + return this.consume(token, 'verify', (_t, user) => { + this.service.db + .update(s.users) + .set({ status: 'active', verifiedAt: Date.now() }) + .where(eq(s.users.id, user.id)) + .run(); + }); + } + async login(email: string, password: string, ip: string, oldToken?: string) { + const normalized = normalizeEmail(email); + this.service.limiter.hit(`login-ip:${ip}`, 20); + this.service.limiter.hit(`login-email:${hash(normalized)}`, 10); + const user = this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get(); + dummy ??= argon2.hash(randomToken(), passwordOptions); + const valid = await argon2 + .verify(user?.passwordHash ?? (await dummy), password) + .catch(() => false); + if (!valid || !user || !['pending', 'active'].includes(user.status)) + fail(401, 'LOGIN_FAILED', '이메일 또는 비밀번호를 확인해 주세요.'); + const token = randomToken(), + now = Date.now(); + this.service.db.transaction(() => { + if (oldToken) + this.service.db + .update(s.accountSessions) + .set({ revokedAt: now }) + .where(eq(s.accountSessions.tokenHash, hash(oldToken))) + .run(); + this.service.db + .insert(s.accountSessions) + .values({ + id: randomUUID(), + userId: user!.id, + tokenHash: hash(token), + createdAt: now, + lastSeen: now, + expiresAt: now + 30 * 86400000, + }) + .run(); + }); + return { token, account: this.serialize(user!), expiresAt: now + 30 * 86400000 }; + } + logout(token: string | undefined) { + const current = this.session(token); + if (!current) return; + this.service.storage.abortSession(current.session.id); + this.service.db + .update(s.accountSessions) + .set({ revokedAt: Date.now() }) + .where(eq(s.accountSessions.id, current.session.id)) + .run(); + this.service.storage.abortSession(current.session.id); + this.service.io?.to(`account-session:${current.session.id}`).emit('account:revoked', {}); + this.service.io + ?.in(`account-session:${current.session.id}`) + .socketsLeave(`account:${current.user.id}`); + } + revoke(userId: string) { + this.service.storage.abortUser(userId); + this.service.storage.abortUser(userId); + this.service.db + .update(s.accountSessions) + .set({ revokedAt: Date.now() }) + .where(eq(s.accountSessions.userId, userId)) + .run(); + this.service.io?.to(`account:${userId}`).emit('account:revoked', {}); + this.service.io?.in(`account:${userId}`).socketsLeave(`account:${userId}`); + } + enforceSessions() { + for (const socket of this.service.io?.sockets.sockets.values() ?? []) { + const id = socket.data.accountSessionId; + if (!id) continue; + const session = this.service.db + .select({ session: s.accountSessions, user: s.users }) + .from(s.accountSessions) + .innerJoin(s.users, eq(s.accountSessions.userId, s.users.id)) + .where(eq(s.accountSessions.id, id)) + .get(); + if ( + !session || + session.session.revokedAt || + session.session.expiresAt <= Date.now() || + !['pending', 'active'].includes(session.user.status) + ) { + socket.leave(`account:${socket.data.accountId}`); + socket.leave(`account-session:${id}`); + socket.data.accountId = undefined; + socket.data.accountSessionId = undefined; + socket.emit('account:revoked', {}); + } + } + } + async resend(email: string, purpose: 'verify' | 'reset', ip: string) { + const normalized = normalizeEmail(email); + this.service.limiter.hit(`mail-ip:${ip}`, 10, 3600000); + this.service.limiter.hit(`mail-email:${hash(normalized)}`, 3, 3600000); + const user = this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get(); + if ( + user && + ((purpose === 'verify' && user.status === 'pending') || + (purpose === 'reset' && ['active', 'pending'].includes(user.status))) + ) + await this.token(user, purpose); + return { message: '처리 가능한 주소라면 안내 메일이 발송됩니다.' }; + } + async resetPassword(token: string, password: string) { + const passwordHash = await this.password(password); + let userId = ''; + const result = this.consume(token, 'reset', (_t, user) => { + userId = user.id; + this.service.db.update(s.users).set({ passwordHash }).where(eq(s.users.id, user.id)).run(); + this.service.db + .update(s.accountTokens) + .set({ usedAt: Date.now() }) + .where(eq(s.accountTokens.userId, user.id)) + .run(); + }); + this.revoke(userId); + return result; + } + async changePassword(user: User, currentPassword: string, password: string) { + if (!user.passwordHash || !(await argon2.verify(user.passwordHash, currentPassword))) + fail(401, 'PASSWORD_INVALID', '현재 비밀번호를 확인해 주세요.'); + const passwordHash = await this.password(password); + this.service.db.update(s.users).set({ passwordHash }).where(eq(s.users.id, user.id)).run(); + this.revoke(user.id); + } + async changeEmail(user: User, email: string, password: string) { + if (!user.passwordHash || !(await argon2.verify(user.passwordHash, password))) + fail(401, 'PASSWORD_INVALID', '현재 비밀번호를 확인해 주세요.'); + const normalized = normalizeEmail(email); + if ( + normalized === user.email || + this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get() + ) + fail(400, 'EMAIL_UNAVAILABLE', '이 주소로 변경할 수 없습니다.'); + await this.token(user, 'email-change', normalized); + } + async verifyEmailChange(token: string) { + let previous = '', + next = ''; + const result = this.consume(token, 'email-change', (t, user) => { + if ( + !t.value || + this.service.db.select().from(s.users).where(eq(s.users.email, t.value)).get() + ) + fail(400, 'EMAIL_UNAVAILABLE', '이 주소로 변경할 수 없습니다.'); + previous = user.email!; + next = t.value!; + this.service.db + .update(s.users) + .set({ email: t.value, verifiedAt: Date.now(), status: 'active' }) + .where(eq(s.users.id, user.id)) + .run(); + }); + await this.sendMail({ + to: previous, + subject: 'hop. 이메일 주소 변경 안내', + text: `계정 이메일이 ${next}로 변경되었습니다.`, + }); + return result; + } +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts new file mode 100644 index 0000000..3cdc1fb --- /dev/null +++ b/apps/server/src/config.ts @@ -0,0 +1,106 @@ +import { fileURLToPath } from 'node:url'; +import { randomBytes } from 'node:crypto'; +export type Config = { + production: boolean; + appUrl: string; + databasePath: string; + sessionSecret: string; + discoverySecret: string; + discoveryEnabled: boolean; + trustedProxies: string[]; + port: number; + host: string; + guestDays: number; + requestSeconds: number; + uploadStartHours: number; + spaceMinutes: number; + spaceMax: number; + offlineGraceMs: number; + maxUrlBytes: number; + maxItems: number; + urlRetentionDays: number; + sessionRate: number; + ipRate: number; + pairRate: number; + rejectCooldownMs: number; + uploadDir: string; + maxFileBytes: number; + guestQuota: number; + totalGuestQuota: number; + minFreeBytes: number; + fileRetentionDays: number; + orphanHours: number; + undoSeconds: number; + registrationMode: 'open' | 'closed'; + setupToken?: string; + mailFrom: string; + resendApiKey?: string; + userQuota: number; + emailRequestDays: number; + webRoot?: string; +}; +export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { + const production = env.NODE_ENV === 'production'; + const num = (key: string, fallback: number, min = 1) => { + const n = Number(env[key] ?? fallback); + if (!Number.isSafeInteger(n) || n < min) throw Error(`Invalid ${key}`); + return n; + }; + const secret = (key: string) => { + const v = env[key]; + if (production && (!v || v.length < 32 || v.startsWith('replace-'))) + throw Error(`${key} must be a random secret (32+ characters)`); + return v && v.length >= 32 ? v : randomBytes(32).toString('base64url'); + }; + const port = num('PORT', 0, 0); + if (production && port === 0) throw Error('Set PORT explicitly before deployment'); + const appUrl = env.APP_URL ?? 'http://127.0.0.1'; + if (production && !appUrl.startsWith('https://')) + throw Error('Production APP_URL must use HTTPS'); + return { + production, + appUrl, + databasePath: + env.DATABASE_PATH ?? fileURLToPath(new URL('../../../.data/hop.sqlite', import.meta.url)), + sessionSecret: secret('SESSION_SECRET'), + discoverySecret: secret('DISCOVERY_SECRET'), + discoveryEnabled: + env.SAME_NETWORK_DISCOVERY_ENABLED === undefined + ? !production + : env.SAME_NETWORK_DISCOVERY_ENABLED === 'true', + trustedProxies: (env.TRUSTED_PROXY_CIDRS ?? '') + .split(',') + .map((v) => v.trim()) + .filter(Boolean), + port, + host: env.HOST ?? '127.0.0.1', + guestDays: num('GUEST_SESSION_RETENTION_DAYS', 30), + requestSeconds: num('LOCAL_REQUEST_TIMEOUT_SECONDS', 60), + uploadStartHours: num('ACCEPTED_UPLOAD_START_HOURS', 24), + spaceMinutes: num('SPACE_RETENTION_MINUTES', 10), + spaceMax: num('SPACE_MAX_DEVICES', 10), + offlineGraceMs: num('OFFLINE_GRACE_MS', 3000, 0), + maxUrlBytes: num('MAX_URL_PAYLOAD_SIZE_KB', 1024) * 1024, + maxItems: num('MAX_ITEMS_PER_TRANSFER', 20), + urlRetentionDays: Math.min(7, num('DEFAULT_URL_RETENTION_DAYS', 7)), + sessionRate: num('GUEST_REQUESTS_PER_MINUTE', 5), + ipRate: num('IP_REQUESTS_PER_MINUTE', 20), + pairRate: num('PAIR_REQUESTS_PER_MINUTE', 2), + rejectCooldownMs: num('REJECT_COOLDOWN_SECONDS', 300) * 1000, + uploadDir: env.UPLOAD_DIR ?? fileURLToPath(new URL('../../../.data/uploads', import.meta.url)), + maxFileBytes: num('MAX_FILE_SIZE_MB', 2048) * 1048576, + guestQuota: num('GUEST_STORAGE_LIMIT_MB', 2048) * 1048576, + totalGuestQuota: num('TOTAL_GUEST_STORAGE_LIMIT_MB', 20480) * 1048576, + minFreeBytes: num('MIN_FREE_DISK_MB', 5120, 0) * 1048576, + fileRetentionDays: Math.min(7, num('DEFAULT_FILE_RETENTION_DAYS', 7)), + orphanHours: num('ORPHAN_UPLOAD_RETENTION_HOURS', 24), + undoSeconds: num('DELETE_UNDO_SECONDS', 5), + registrationMode: env.REGISTRATION_MODE === 'open' ? 'open' : 'closed', + setupToken: env.SETUP_TOKEN, + resendApiKey: env.RESEND_API_KEY?.trim() || undefined, + mailFrom: env.MAIL_FROM ?? 'hop. ', + userQuota: num('USER_STORAGE_LIMIT_MB', 10240) * 1048576, + emailRequestDays: num('EMAIL_REQUEST_RETENTION_DAYS', 7), + webRoot: env.WEB_ROOT, + }; +} diff --git a/apps/server/src/db/index.ts b/apps/server/src/db/index.ts new file mode 100644 index 0000000..7b4ae27 --- /dev/null +++ b/apps/server/src/db/index.ts @@ -0,0 +1,57 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { readMigrationFiles } from 'drizzle-orm/migrator'; +import { sql } from 'drizzle-orm'; +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from './schema.js'; +export function openDatabase(path: string) { + if (path !== ':memory:') mkdirSync(dirname(resolve(path)), { recursive: true }); + const sqlite = new Database(path); + sqlite.pragma('journal_mode = WAL'); + sqlite.pragma('busy_timeout = 5000'); + const db = drizzle(sqlite, { schema }); + // SQLite table rebuilds require foreign_keys OFF outside the transaction. Check all references + // before committing, then restore enforcement before accepting any application request. + sqlite.pragma('foreign_keys = OFF'); + try { + sqlite.exec( + 'CREATE TABLE IF NOT EXISTS __drizzle_migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL, created_at INTEGER)', + ); + const migrations = readMigrationFiles({ + migrationsFolder: fileURLToPath(new URL('../../drizzle', import.meta.url)), + }); + db.transaction((tx) => { + const latest = sqlite + .prepare('SELECT created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1') + .get() as { created_at: number } | undefined; + for (const migration of migrations) { + if (latest && migration.folderMillis <= latest.created_at) continue; + // A development server applied the first account schema before its deletion-column + // correction was generated. Preserve that ledger/data; migration 0005 applies the sole + // remaining structural difference. Accept only this exact observed legacy revision. + const legacy = sqlite + .prepare('SELECT hash FROM __drizzle_migrations WHERE created_at = ?') + .get(1791448938074) as { hash: string } | undefined; + const legacyAccountRevision = + migration === migrations[4] && + legacy?.hash === 'fb1af44cf19e4f77c50f07608ba046819dce40cae3925e0b632d1d9afa045068'; + if (!legacyAccountRevision) + for (const statement of migration.sql) tx.run(sql.raw(statement)); + sqlite + .prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)') + .run(migration.hash, migration.folderMillis); + } + if ((sqlite.pragma('foreign_key_check') as unknown[]).length) + throw Error('Migration foreign-key check failed'); + }); + } catch (error) { + sqlite.close(); + throw error; + } finally { + if (sqlite.open) sqlite.pragma('foreign_keys = ON'); + } + return { db, sqlite }; +} +export type DB = ReturnType['db']; diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts new file mode 100644 index 0000000..b0b0161 --- /dev/null +++ b/apps/server/src/db/schema.ts @@ -0,0 +1,354 @@ +import { sqliteTable, text, integer, index, uniqueIndex, check } from 'drizzle-orm/sqlite-core'; +import { sql } from 'drizzle-orm'; +export const users = sqliteTable('users', { + id: text().primaryKey(), + email: text().unique(), + displayName: text().notNull(), + passwordHash: text(), + role: text({ enum: ['user', 'admin'] }) + .notNull() + .default('user'), + status: text({ enum: ['pending', 'active', 'suspended', 'deleted'] }).notNull(), + verifiedAt: integer(), + createdAt: integer().notNull(), + receivePolicy: text({ enum: ['requests', 'allowed', 'none'] }) + .notNull() + .default('requests'), +}); +export const accountSessions = sqliteTable( + 'account_sessions', + { + id: text().primaryKey(), + userId: text() + .notNull() + .references(() => users.id), + tokenHash: text().notNull().unique(), + createdAt: integer().notNull(), + lastSeen: integer().notNull(), + expiresAt: integer().notNull(), + revokedAt: integer(), + }, + (t) => [index('account_session_user').on(t.userId, t.expiresAt)], +); +export const accountTokens = sqliteTable( + 'account_tokens', + { + id: text().primaryKey(), + userId: text() + .notNull() + .references(() => users.id), + purpose: text({ enum: ['verify', 'reset', 'email-change'] }).notNull(), + tokenHash: text().notNull().unique(), + value: text(), + createdAt: integer().notNull(), + expiresAt: integer().notNull(), + usedAt: integer(), + }, + (t) => [index('account_token_expiry').on(t.expiresAt)], +); +export const rules = sqliteTable( + 'user_rules', + { + id: text().primaryKey(), + userId: text() + .notNull() + .references(() => users.id), + targetUserId: text() + .notNull() + .references(() => users.id), + action: text({ enum: ['allow', 'block'] }).notNull(), + }, + (t) => [ + uniqueIndex('user_rule_pair').on(t.userId, t.targetUserId), + check('rule_not_self', sql`${t.userId} <> ${t.targetUserId}`), + ], +); +export const guests = sqliteTable( + 'guest_sessions', + { + id: text().primaryKey(), + tokenHash: text().notNull().unique(), + expiresAt: integer().notNull(), + revokedAt: integer(), + createdAt: integer().notNull(), + }, + (t) => [index('guest_expiry').on(t.expiresAt)], +); +export const devices = sqliteTable( + 'browser_devices', + { + id: text().primaryKey(), + guestId: text() + .notNull() + .unique() + .references(() => guests.id), + name: text().notNull(), + code: text().notNull().unique(), + discoverable: integer({ mode: 'boolean' }).notNull().default(true), + lastSeen: integer().notNull(), + }, + (t) => [check('device_name_length', sql`length(${t.name}) between 1 and 40`)], +); +export const spaces = sqliteTable( + 'connection_spaces', + { + id: text().primaryKey(), + creatorGuestId: text() + .notNull() + .references(() => guests.id), + tokenHash: text().notNull().unique(), + codeHash: text().notNull().unique(), + approvalRequired: integer({ mode: 'boolean' }).notNull(), + expiresAt: integer().notNull(), + closedAt: integer(), + createdAt: integer().notNull(), + }, + (t) => [index('space_expiry').on(t.expiresAt)], +); +export const members = sqliteTable( + 'space_members', + { + id: text().primaryKey(), + spaceId: text() + .notNull() + .references(() => spaces.id), + deviceId: text() + .notNull() + .references(() => devices.id), + status: text({ enum: ['pending', 'approved', 'removed'] }).notNull(), + joinedAt: integer().notNull(), + }, + (t) => [ + uniqueIndex('space_device').on(t.spaceId, t.deviceId), + uniqueIndex('member_single_active_device') + .on(t.deviceId) + .where(sql`${t.status} IN ('pending', 'approved')`), + ], +); +export const blocks = sqliteTable( + 'guest_blocks', + { + id: text().primaryKey(), + guestId: text() + .notNull() + .references(() => guests.id), + targetGuestId: text() + .notNull() + .references(() => guests.id), + }, + (t) => [uniqueIndex('guest_block_pair').on(t.guestId, t.targetGuestId)], +); +export const requests = sqliteTable( + 'transfer_requests', + { + id: text().primaryKey(), + mode: text({ enum: ['local', 'email'] }) + .notNull() + .default('local'), + senderGuestId: text().references(() => guests.id), + recipientGuestId: text().references(() => guests.id), + senderUserId: text().references(() => users.id), + recipientUserId: text().references(() => users.id), + kind: text({ enum: ['text', 'url', 'file'] }).notNull(), + byteSize: integer().notNull(), + manifest: text(), + note: text(), + count: integer().notNull(), + status: text({ enum: ['pending', 'accepted', 'rejected', 'expired', 'cancelled'] }).notNull(), + createdAt: integer().notNull(), + expiresAt: integer().notNull(), + acceptedAt: integer(), + rejectedAt: integer(), + startBy: integer(), + }, + (t) => [ + index('requests_sender_time').on(t.senderGuestId, t.createdAt), + index('requests_recipient_time').on(t.recipientGuestId, t.createdAt), + index('requests_expiry').on(t.status, t.expiresAt), + check( + 'request_different_guests', + sql`${t.mode} <> 'local' OR ${t.senderGuestId} <> ${t.recipientGuestId}`, + ), + check( + 'request_sender_subject', + sql`(${t.senderGuestId} IS NOT NULL) + (${t.senderUserId} IS NOT NULL) = 1`, + ), + check( + 'request_recipient_subject', + sql`(${t.recipientGuestId} IS NOT NULL) + (${t.recipientUserId} IS NOT NULL) = 1`, + ), + check( + 'request_mode_subjects', + sql`(${t.mode} = 'local' AND ${t.senderGuestId} IS NOT NULL AND ${t.recipientGuestId} IS NOT NULL) OR (${t.mode} = 'email' AND ${t.senderUserId} IS NOT NULL AND ${t.recipientUserId} IS NOT NULL)`, + ), + index('requests_sender_user').on(t.senderUserId, t.createdAt), + index('requests_recipient_user').on(t.recipientUserId, t.createdAt), + ], +); +export const batches = sqliteTable('transfer_batches', { + id: text().primaryKey(), + requestId: text() + .notNull() + .unique() + .references(() => requests.id), + status: text({ enum: ['waiting', 'uploading', 'stored', 'failed'] }).notNull(), + storedAt: integer(), +}); +export const files = sqliteTable( + 'files', + { + id: text().primaryKey(), + requestId: text() + .notNull() + .references(() => requests.id), + ownerGuestId: text().references(() => guests.id), + ownerUserId: text().references(() => users.id), + position: integer().notNull(), + storageKey: text().notNull().unique(), + name: text().notNull(), + type: text().notNull(), + size: integer().notNull(), + checksum: text(), + status: text({ enum: ['pending', 'uploading', 'stored', 'failed', 'cleaning'] }).notNull(), + uploadId: text(), + uploadKey: text(), + updatedAt: integer().notNull(), + }, + (t) => [ + uniqueIndex('request_file_position').on(t.requestId, t.position), + index('files_owner_state').on(t.ownerGuestId, t.status), + check('file_nonnegative_size', sql`${t.size} >= 0`), + check( + 'file_owner_subject', + sql`(${t.ownerGuestId} IS NOT NULL) + (${t.ownerUserId} IS NOT NULL) = 1`, + ), + ], +); +export const reservations = sqliteTable( + 'quota_reservations', + { + id: text().primaryKey(), + fileId: text() + .notNull() + .unique() + .references(() => files.id), + userId: text().references(() => users.id), + guestId: text().references(() => guests.id), + bytes: integer().notNull(), + expiresAt: integer().notNull(), + }, + (t) => [ + check('reservations_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`), + index('quota_guest_expiry').on(t.guestId, t.expiresAt), + check('reservation_nonnegative', sql`${t.bytes} >= 0`), + ], +); +export const deletions = sqliteTable( + 'deletion_operations', + { + id: text().primaryKey(), + userId: text().references(() => users.id), + guestId: text().references(() => guests.id), + undoUntil: integer().notNull(), + createdAt: integer().notNull(), + restoredAt: integer(), + }, + (t) => [ + check('deletion_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`), + ], +); +export const deletionTargets = sqliteTable('deletion_targets', { + id: text().primaryKey(), + operationId: text() + .notNull() + .references(() => deletions.id), + accessId: text().notNull(), +}); +export const items = sqliteTable( + 'items', + { + id: text().primaryKey(), + batchId: text() + .notNull() + .references(() => batches.id), + kind: text({ enum: ['text', 'url', 'file'] }).notNull(), + text: text(), + url: text(), + fileId: text().references(() => files.id), + createdAt: integer().notNull(), + expiresAt: integer().notNull(), + }, + (t) => [ + index('items_expiry').on(t.expiresAt), + check( + 'item_payload', + sql`(${t.kind} = 'text' AND ${t.text} IS NOT NULL AND ${t.url} IS NULL AND ${t.fileId} IS NULL) OR (${t.kind} = 'url' AND ${t.url} IS NOT NULL AND ${t.text} IS NULL AND ${t.fileId} IS NULL) OR (${t.kind} = 'file' AND ${t.fileId} IS NOT NULL AND ${t.text} IS NULL AND ${t.url} IS NULL)`, + ), + ], +); +export const access = sqliteTable( + 'item_access', + { + id: text().primaryKey(), + itemId: text() + .notNull() + .references(() => items.id), + userId: text().references(() => users.id), + guestId: text().references(() => guests.id), + role: text({ enum: ['sender', 'recipient', 'self'] }).notNull(), + read: integer({ mode: 'boolean' }).notNull().default(false), + deletedAt: integer(), + favorite: integer({ mode: 'boolean' }).notNull().default(false), + note: text(), + }, + (t) => [ + check('access_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`), + uniqueIndex('access_subject_role').on(t.itemId, t.guestId, t.role), + uniqueIndex('access_account_role').on(t.itemId, t.userId, t.role), + index('access_account').on(t.userId, t.deletedAt), + index('access_guest').on(t.guestId, t.deletedAt), + ], +); +export const receiptAccounts = sqliteTable('request_receipt_accounts', { + requestId: text() + .primaryKey() + .references(() => requests.id), + userId: text() + .notNull() + .references(() => users.id), + sessionId: text() + .notNull() + .references(() => accountSessions.id), +}); +export const vaultTransfers = sqliteTable('vault_transfers', { + requestId: text() + .primaryKey() + .references(() => requests.id), + accessId: text() + .notNull() + .references(() => access.id), + userId: text() + .notNull() + .references(() => users.id), + sessionId: text() + .notNull() + .references(() => accountSessions.id), +}); +export const idempotency = sqliteTable( + 'idempotency_records', + { + id: text().primaryKey(), + userId: text().references(() => users.id), + guestId: text().references(() => guests.id), + operation: text().notNull(), + key: text().notNull(), + payloadHash: text().notNull(), + response: text().notNull(), + createdAt: integer().notNull(), + }, + (t) => [ + uniqueIndex('idempotency_scope').on(t.guestId, t.operation, t.key), + uniqueIndex('account_idempotency_scope').on(t.userId, t.operation, t.key), + check('idempotency_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`), + ], +); diff --git a/apps/server/src/mail.ts b/apps/server/src/mail.ts new file mode 100644 index 0000000..5194470 --- /dev/null +++ b/apps/server/src/mail.ts @@ -0,0 +1,48 @@ +import { randomUUID } from 'node:crypto'; +import type { Config } from './config.js'; +import type { Mailer } from './auth.js'; +import { ApiError } from './security.js'; + +/** The API key stays in the server process. Delivery receipts and provider error bodies + * never become public API responses or application logs. */ +export function createResendMailer( + cfg: Pick, + fetcher: typeof fetch = fetch, +): Mailer | undefined { + if (!cfg.resendApiKey) return undefined; + return async (mail) => { + try { + const response = await fetcher('https://api.resend.com/emails', { + method: 'POST', + redirect: 'error', + headers: { + Authorization: `Bearer ${cfg.resendApiKey}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': mail.idempotencyKey ?? randomUUID(), + }, + body: JSON.stringify({ + from: cfg.mailFrom, + to: [mail.to], + subject: mail.subject, + text: mail.text, + }), + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) throw new Error('Delivery rejected'); + const receipt: unknown = await response.json(); + if ( + !receipt || + typeof receipt !== 'object' || + !('id' in receipt) || + typeof receipt.id !== 'string' + ) + throw new Error('Invalid receipt'); + } catch { + throw new ApiError( + 503, + 'MAIL_DELIVERY_FAILED', + '메일을 보내지 못했습니다. 잠시 후 다시 시도해 주세요.', + ); + } + }; +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts new file mode 100644 index 0000000..f2c140a --- /dev/null +++ b/apps/server/src/main.ts @@ -0,0 +1,13 @@ +import { loadConfig } from './config.js'; +import { createApp } from './app.js'; +const cfg = loadConfig(); +const runtime = await createApp(cfg); +await runtime.app.listen({ port: cfg.port, host: cfg.host }); +const address = runtime.app.server.address(); +if (!address || typeof address === 'string') throw Error('No server address'); +if (!cfg.production) runtime.setDevOrigin(`http://${cfg.host}:${address.port}`); +process.stdout.write(`hop. ready at http://${cfg.host}:${address.port}\n`); +for (const signal of ['SIGTERM', 'SIGINT'] as const) + process.on(signal, () => { + void runtime.app.close(); + }); diff --git a/apps/server/src/presence.ts b/apps/server/src/presence.ts new file mode 100644 index 0000000..90b8f4b --- /dev/null +++ b/apps/server/src/presence.ts @@ -0,0 +1,77 @@ +import type { Server, Socket } from 'socket.io'; +import type { Config } from './config.js'; +import { hmac, publicIp } from './security.js'; +export class Presence { + private connections = new Map>(); + private graceIps = new Map>(); + private timers = new Map>(); + constructor( + private io: Server, + private cfg: Config, + private changed: () => void, + ) {} + connect(guestId: string, socket: Socket, ip: string | null) { + const timer = this.timers.get(guestId); + if (timer) clearTimeout(timer); + this.timers.delete(guestId); + this.graceIps.delete(guestId); + const sockets = this.connections.get(guestId) ?? new Map(); + sockets.set(socket.id, ip); + this.connections.set(guestId, sockets); + socket.join(`guest:${guestId}`); + this.changed(); + socket.on('disconnect', () => this.disconnect(guestId, socket.id)); + } + private disconnect(guestId: string, id: string) { + const sockets = this.connections.get(guestId); + const oldIp = sockets?.get(id) ?? null; + sockets?.delete(id); + if (sockets?.size) return; + this.graceIps.set(guestId, new Set([oldIp])); + const timer = setTimeout(() => { + this.connections.delete(guestId); + this.graceIps.delete(guestId); + this.timers.delete(guestId); + this.changed(); + }, this.cfg.offlineGraceMs); + timer.unref(); + this.timers.set(guestId, timer); + } + online(id: string) { + return this.connections.has(id); + } + active(id: string) { + return Boolean(this.connections.get(id)?.size); + } + sameNetwork(a: string, b: string) { + if (!this.cfg.discoveryEnabled) return false; + const groups = (id: string) => + new Set( + [...(this.connections.get(id)?.values() ?? []), ...(this.graceIps.get(id) ?? [])] + .filter( + (ip) => publicIp(ip) || (!this.cfg.production && (ip === '127.0.0.1' || ip === '::1')), + ) + .map((ip) => hmac(this.cfg.discoverySecret, ip!)), + ); + const ag = groups(a); + return [...groups(b)].some((v) => ag.has(v)); + } + emit(id: string, event: string, payload: unknown) { + this.io.to(`guest:${id}`).emit(event, payload); + } + ids() { + return [...this.connections.keys()].filter((id) => this.online(id)); + } + revoke(id: string) { + this.io.in(`guest:${id}`).disconnectSockets(true); + this.connections.delete(id); + this.graceIps.delete(id); + const timer = this.timers.get(id); + if (timer) clearTimeout(timer); + this.timers.delete(id); + this.changed(); + } + close() { + for (const t of this.timers.values()) clearTimeout(t); + } +} diff --git a/apps/server/src/security.ts b/apps/server/src/security.ts new file mode 100644 index 0000000..ea62cd5 --- /dev/null +++ b/apps/server/src/security.ts @@ -0,0 +1,84 @@ +import { createHash, createHmac, randomBytes } from 'node:crypto'; +import ipaddr from 'ipaddr.js'; +import type { IncomingHttpHeaders } from 'node:http'; +export class ApiError extends Error { + constructor( + public status: number, + public code: string, + message: string, + ) { + super(message); + } +} +export const fail = (status: number, code: string, message: string): never => { + throw new ApiError(status, code, message); +}; +export const randomToken = () => randomBytes(32).toString('base64url'); +export const hash = (value: string) => createHash('sha256').update(value).digest('hex'); +export const hmac = (secret: string, value: string) => + createHmac('sha256', secret).update(value).digest('hex'); +export function normalizeIp(value: string): string | null { + try { + return ipaddr.process(value).toString(); + } catch { + return null; + } +} +export function clientIp( + remote: string | undefined, + headers: IncomingHttpHeaders, + trusted: string[], +): string | null { + const first = remote ? normalizeIp(remote) : null; + if (!first) return null; + const trusts = (v: string) => + trusted.some((rule) => { + try { + const a = ipaddr.process(v); + const cidr = rule.includes('/') + ? ipaddr.parseCIDR(rule) + : ([ipaddr.process(rule), a.kind() === 'ipv4' ? 32 : 128] as [ + ipaddr.IPv4 | ipaddr.IPv6, + number, + ]); + return a.kind() === cidr[0].kind() && a.match(cidr); + } catch { + return false; + } + }); + if (!trusts(first)) return first; + const raw = headers['x-forwarded-for']; + if (typeof raw !== 'string') return null; + const chain = raw.split(',').map((v) => normalizeIp(v.trim())); + if (chain.some((v) => !v)) return null; + let current = first; + for (const next of chain.reverse()) { + if (!trusts(current)) break; + current = next!; + } + return current; +} +export function publicIp(ip: string | null): boolean { + if (!ip) return false; + try { + return ipaddr.process(ip).range() === 'unicast'; + } catch { + return false; + } +} +export class Limiter { + private windows = new Map(); + hit(key: string, max: number, windowMs = 60000) { + const now = Date.now(); + let w = this.windows.get(key); + if (!w || w.until <= now) { + w = { count: 0, until: now + windowMs }; + this.windows.set(key, w); + } + if (++w.count > max) fail(429, 'RATE_LIMIT', '요청이 많습니다. 잠시 후 다시 시도해 주세요.'); + } + prune() { + const now = Date.now(); + for (const [key, w] of this.windows) if (w.until <= now) this.windows.delete(key); + } +} diff --git a/apps/server/src/service.ts b/apps/server/src/service.ts new file mode 100644 index 0000000..321d582 --- /dev/null +++ b/apps/server/src/service.ts @@ -0,0 +1,1047 @@ +import type { Server } from 'socket.io'; +import type { Auth } from './auth.js'; +import type { Accounts } from './accounts.js'; +import { Storage } from './storage.js'; +import { Vault } from './vault.js'; +import { randomInt, randomUUID } from 'node:crypto'; +import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm'; +import type { DB } from './db/index.js'; +import * as s from './db/schema.js'; +import type { Config } from './config.js'; +import type { Presence } from './presence.js'; +import { fail, hash, hmac, randomToken, Limiter } from './security.js'; +import { + parseUrls, + type Device, + type Item, + type Peer, + type Space, + type TransferRequest, +} from '@hop/shared'; +export type Guest = typeof s.guests.$inferSelect; +export type RequestRow = typeof s.requests.$inferSelect; +const day = 86400000; +export class Service { + readonly limiter = new Limiter(); + presence!: Presence; + auth!: Auth; + accounts!: Accounts; + io?: Server; + storage: Storage; + vault: Vault; + constructor( + readonly db: DB, + readonly cfg: Config, + ) { + this.storage = new Storage(this); + this.vault = new Vault(this); + } + guest(token: string | undefined) { + if (!token) return undefined; + return this.db + .select() + .from(s.guests) + .where( + and( + eq(s.guests.tokenHash, hash(token)), + isNull(s.guests.revokedAt), + gt(s.guests.expiresAt, Date.now()), + ), + ) + .get(); + } + device(guestId: string): Device { + const d = this.db.select().from(s.devices).where(eq(s.devices.guestId, guestId)).get(); + if (!d) fail(401, 'SESSION_EXPIRED', '기기 세션이 만료되었습니다.'); + return { id: d!.id, name: d!.name, code: d!.code, discoverable: d!.discoverable }; + } + deviceGuest(id: string) { + return this.db.select().from(s.devices).where(eq(s.devices.id, id)).get(); + } + createGuest(platform: string) { + const now = Date.now(), + token = randomToken(), + id = randomUUID(); + const name = /android/i.test(platform) + ? 'Android' + : /ipad/i.test(platform) + ? 'iPad' + : /iphone/i.test(platform) + ? 'iPhone' + : /mac/i.test(platform) + ? 'Mac' + : /windows/i.test(platform) + ? 'Windows PC' + : /linux/i.test(platform) + ? 'Linux PC' + : '나의 기기'; + this.db.transaction(() => { + this.db + .insert(s.guests) + .values({ + id, + tokenHash: hash(token), + expiresAt: now + this.cfg.guestDays * day, + createdAt: now, + }) + .run(); + this.db + .insert(s.devices) + .values({ + id: randomUUID(), + guestId: id, + name, + code: randomToken().slice(0, 6).toUpperCase(), + discoverable: true, + lastSeen: now, + }) + .run(); + }); + return { token, guest: this.guest(token)! }; + } + csrf(token: string) { + return hmac(this.cfg.sessionSecret, `csrf:${token}`); + } + patchDevice(g: Guest, input: { name?: string; discoverable?: boolean }) { + this.db + .update(s.devices) + .set({ ...input, lastSeen: Date.now() }) + .where(eq(s.devices.guestId, g.id)) + .run(); + this.refresh(); + } + reset(g: Guest) { + for (const row of this.db + .select() + .from(s.requests) + .where( + and( + inArray(s.requests.kind, ['url', 'file']), + or(eq(s.requests.senderGuestId, g.id), eq(s.requests.recipientGuestId, g.id)), + ), + ) + .all()) + this.storage.release(row.id); + this.db.transaction(() => { + this.db.update(s.guests).set({ revokedAt: Date.now() }).where(eq(s.guests.id, g.id)).run(); + this.db + .update(s.access) + .set({ deletedAt: Date.now() }) + .where(eq(s.access.guestId, g.id)) + .run(); + this.db + .update(s.requests) + .set({ status: 'cancelled' }) + .where( + and( + or(eq(s.requests.senderGuestId, g.id), eq(s.requests.recipientGuestId, g.id)), + inArray(s.requests.status, ['pending', 'accepted']), + ), + ) + .run(); + this.db + .update(s.spaces) + .set({ closedAt: Date.now() }) + .where(eq(s.spaces.creatorGuestId, g.id)) + .run(); + }); + this.presence.revoke(g.id); + this.notifyRequests(); + } + isBlocked(a: string, b: string) { + return Boolean( + this.db + .select() + .from(s.blocks) + .where( + or( + and(eq(s.blocks.guestId, a), eq(s.blocks.targetGuestId, b)), + and(eq(s.blocks.guestId, b), eq(s.blocks.targetGuestId, a)), + ), + ) + .get(), + ); + } + sharedSpace(a: string, b: string) { + const da = this.device(a).id, + db = this.device(b).id; + const mine = this.db + .select({ spaceId: s.members.spaceId }) + .from(s.members) + .innerJoin(s.spaces, eq(s.members.spaceId, s.spaces.id)) + .where( + and( + eq(s.members.deviceId, da), + eq(s.members.status, 'approved'), + isNull(s.spaces.closedAt), + gt(s.spaces.expiresAt, Date.now()), + ), + ) + .all(); + return mine.some((row) => + Boolean( + this.db + .select() + .from(s.members) + .where( + and( + eq(s.members.spaceId, row.spaceId), + eq(s.members.deviceId, db), + eq(s.members.status, 'approved'), + ), + ) + .get(), + ), + ); + } + activeRoom(guestId: string) { + return this.db + .select({ id: s.spaces.id }) + .from(s.spaces) + .innerJoin(s.members, eq(s.members.spaceId, s.spaces.id)) + .where( + and( + eq(s.members.deviceId, this.device(guestId).id), + inArray(s.members.status, ['pending', 'approved']), + isNull(s.spaces.closedAt), + gt(s.spaces.expiresAt, Date.now()), + ), + ) + .get(); + } + scope(a: string, b: string) { + if (this.isBlocked(a, b)) return false; + if (this.activeRoom(a) || this.activeRoom(b)) return this.sharedSpace(a, b); + return this.presence.sameNetwork(a, b); + } + restrictTransfers(leaving: string[] = []) { + const active = this.db + .select({ request: s.requests, batch: s.batches }) + .from(s.requests) + .innerJoin(s.batches, eq(s.batches.requestId, s.requests.id)) + .where( + and( + eq(s.requests.mode, 'local'), + inArray(s.requests.status, ['pending', 'accepted']), + inArray(s.batches.status, ['waiting', 'uploading']), + ), + ) + .all(); + for (const { request, batch } of active) { + if ( + !leaving.includes(request.senderGuestId!) && + !leaving.includes(request.recipientGuestId!) && + this.scope(request.senderGuestId!, request.recipientGuestId!) + ) + continue; + this.storage.release(request.id); + this.db + .update(s.requests) + .set({ status: 'cancelled' }) + .where(eq(s.requests.id, request.id)) + .run(); + this.db.update(s.batches).set({ status: 'failed' }).where(eq(s.batches.id, batch.id)).run(); + this.notifyTransfer(request.id); + } + } + canReceive(a: string, b: string, requireActive = true) { + const guest = this.db + .select() + .from(s.guests) + .where( + and(eq(s.guests.id, b), isNull(s.guests.revokedAt), gt(s.guests.expiresAt, Date.now())), + ) + .get(); + return Boolean( + guest && + (requireActive ? this.presence.active(b) : this.presence.online(b)) && + this.device(b).discoverable && + this.scope(a, b), + ); + } + peers(g: Guest): Peer[] { + if (!this.device(g.id).discoverable) return []; + return this.presence + .ids() + .filter((id) => id !== g.id && this.canReceive(g.id, id, false)) + .map((id) => ({ + ...this.device(id), + sources: [ + ...(!this.activeRoom(g.id) && this.presence.sameNetwork(g.id, id) + ? ['network' as const] + : []), + ...(this.sharedSpace(g.id, id) ? ['space' as const] : []), + ], + })); + } + refresh() { + if (!this.presence) return; + for (const id of this.presence.ids()) { + const g = this.db.select().from(s.guests).where(eq(s.guests.id, id)).get(); + if (!g || g.revokedAt || g.expiresAt <= Date.now()) { + this.presence.revoke(id); + continue; + } + this.presence.emit(id, 'device:updated', { devices: this.peers(g) }); + this.presence.emit(id, 'space:updated', {}); + } + } + space(g: Guest, id: string): Space { + const row = this.db + .select() + .from(s.spaces) + .where( + and(eq(s.spaces.id, id), isNull(s.spaces.closedAt), gt(s.spaces.expiresAt, Date.now())), + ) + .get(); + if (!row) fail(404, 'SPACE_NOT_FOUND', '연결 공간이 없거나 만료되었습니다.'); + const myDevice = this.device(g.id); + const member = this.db + .select() + .from(s.members) + .where(and(eq(s.members.spaceId, id), eq(s.members.deviceId, myDevice.id))) + .get(); + if (row!.creatorGuestId !== g.id && (!member || member.status === 'removed')) + fail(404, 'SPACE_NOT_FOUND', '연결 공간을 확인할 수 없습니다.'); + const all = this.db + .select({ device: s.devices, status: s.members.status }) + .from(s.members) + .innerJoin(s.devices, eq(s.members.deviceId, s.devices.id)) + .where(and(eq(s.members.spaceId, id), inArray(s.members.status, ['pending', 'approved']))) + .all(); + return { + id, + creatorDeviceId: this.device(row!.creatorGuestId).id, + approvalRequired: row!.approvalRequired, + expiresAt: row!.expiresAt, + members: all + .filter( + (m) => + row!.creatorGuestId === g.id || + (member?.status === 'approved' && m.status === 'approved') || + m.device.id === myDevice.id, + ) + .map((m) => ({ + device: { + id: m.device.id, + name: m.device.name, + code: m.device.code, + discoverable: m.device.discoverable, + }, + status: m.status as 'pending' | 'approved', + })), + }; + } + listSpaces(g: Guest) { + const mine = this.db + .select({ id: s.spaces.id }) + .from(s.spaces) + .innerJoin(s.members, eq(s.members.spaceId, s.spaces.id)) + .where( + and( + eq(s.members.deviceId, this.device(g.id).id), + inArray(s.members.status, ['approved', 'pending']), + isNull(s.spaces.closedAt), + gt(s.spaces.expiresAt, Date.now()), + ), + ) + .all(); + return mine.map((row) => this.space(g, row.id)); + } + createSpace(g: Guest, approvalRequired: boolean) { + this.limiter.hit(`space-create:${g.id}`, 5); + this.cleanup(); + if (this.activeRoom(g.id)) fail(409, 'SPACE_LIMIT', '현재 방에서 먼저 나가 주세요.'); + const now = Date.now(), + id = randomUUID(), + token = randomToken(); + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + const code = Array.from({ length: 8 }, () => alphabet[randomInt(alphabet.length)]).join(''); + this.db.transaction(() => { + this.db + .insert(s.spaces) + .values({ + id, + creatorGuestId: g.id, + tokenHash: hash(token), + codeHash: hmac(this.cfg.discoverySecret, code), + approvalRequired, + expiresAt: now + this.cfg.spaceMinutes * 60000, + createdAt: now, + }) + .run(); + this.db + .insert(s.members) + .values({ + id: randomUUID(), + spaceId: id, + deviceId: this.device(g.id).id, + status: 'approved', + joinedAt: now, + }) + .run(); + }); + this.restrictTransfers(); + this.refresh(); + return { space: this.space(g, id), code, token }; + } + joinSpace(g: Guest, input: { code?: string; token?: string }, ip: string) { + this.cleanup(); + this.limiter.hit(`space-join-ip:${ip}`, 20); + this.limiter.hit(`space-join:${g.id}`, 10); + const condition = input.token + ? eq(s.spaces.tokenHash, hash(input.token)) + : eq(s.spaces.codeHash, hmac(this.cfg.discoverySecret, input.code!)); + const row = this.db + .select() + .from(s.spaces) + .where(and(condition, isNull(s.spaces.closedAt), gt(s.spaces.expiresAt, Date.now()))) + .get(); + if (!row) fail(404, 'SPACE_NOT_FOUND', '코드가 올바르지 않거나 만료되었습니다.'); + this.db.transaction(() => { + const deviceId = this.device(g.id).id; + const existing = this.db + .select() + .from(s.members) + .where(and(eq(s.members.spaceId, row!.id), eq(s.members.deviceId, deviceId))) + .get(); + if (existing?.status === 'removed') + fail(403, 'SPACE_REMOVED', '이 공간에 다시 참여할 수 없습니다.'); + if (existing) return; + const count = this.db + .select() + .from(s.members) + .where( + and(eq(s.members.spaceId, row!.id), inArray(s.members.status, ['pending', 'approved'])), + ) + .all().length; + if (count >= this.cfg.spaceMax) fail(409, 'SPACE_FULL', '연결 공간이 가득 찼습니다.'); + if (this.activeRoom(g.id)) fail(409, 'SPACE_LIMIT', '현재 방에서 먼저 나가 주세요.'); + this.db + .insert(s.members) + .values({ + id: randomUUID(), + spaceId: row!.id, + deviceId, + status: row!.approvalRequired ? 'pending' : 'approved', + joinedAt: Date.now(), + }) + .run(); + }); + this.restrictTransfers(); + this.refresh(); + return { space: this.space(g, row!.id) }; + } + ownerSpace(g: Guest, id: string) { + const row = this.db + .select() + .from(s.spaces) + .where( + and( + eq(s.spaces.id, id), + eq(s.spaces.creatorGuestId, g.id), + isNull(s.spaces.closedAt), + gt(s.spaces.expiresAt, Date.now()), + ), + ) + .get(); + if (!row) fail(404, 'SPACE_NOT_FOUND', '연결 공간을 관리할 수 없습니다.'); + return row!; + } + approve(g: Guest, id: string, deviceId: string) { + this.ownerSpace(g, id); + const member = this.db + .select() + .from(s.members) + .where( + and( + eq(s.members.spaceId, id), + eq(s.members.deviceId, deviceId), + eq(s.members.status, 'pending'), + ), + ) + .get(); + if (!member) fail(404, 'MEMBER_NOT_FOUND', '승인할 참여자가 없습니다.'); + this.db.update(s.members).set({ status: 'approved' }).where(eq(s.members.id, member!.id)).run(); + this.refresh(); + } + remove(g: Guest, id: string, deviceId: string) { + if (this.device(g.id).id !== deviceId) this.ownerSpace(g, id); + else this.space(g, id); + if (this.ownerOrNull(g, id) && this.device(g.id).id === deviceId) + fail(409, 'CREATOR_LEAVE', '공간 생성자는 공간을 닫아 주세요.'); + const member = this.db + .select() + .from(s.members) + .where(and(eq(s.members.spaceId, id), eq(s.members.deviceId, deviceId))) + .get(); + if (!member) fail(404, 'MEMBER_NOT_FOUND', '참여자를 찾을 수 없습니다.'); + this.db.update(s.members).set({ status: 'removed' }).where(eq(s.members.id, member!.id)).run(); + this.restrictTransfers([this.deviceGuest(deviceId)!.guestId]); + this.refresh(); + } + ownerOrNull(g: Guest, id: string) { + return this.db + .select() + .from(s.spaces) + .where(and(eq(s.spaces.id, id), eq(s.spaces.creatorGuestId, g.id))) + .get(); + } + closeSpace(g: Guest, id: string) { + this.ownerSpace(g, id); + const guests = this.db + .select({ guestId: s.devices.guestId }) + .from(s.members) + .innerJoin(s.devices, eq(s.members.deviceId, s.devices.id)) + .where(eq(s.members.spaceId, id)) + .all() + .map((m) => m.guestId); + this.db.update(s.spaces).set({ closedAt: Date.now() }).where(eq(s.spaces.id, id)).run(); + this.db.update(s.members).set({ status: 'removed' }).where(eq(s.members.spaceId, id)).run(); + this.restrictTransfers(guests); + this.refresh(); + } + idem(g: Guest, operation: string, key: string | undefined, payload: unknown, fn: () => T): T { + if (!key || !/^[A-Za-z0-9_-]{8,128}$/.test(key)) + fail(400, 'IDEMPOTENCY_REQUIRED', '유효한 중복 방지 키가 필요합니다.'); + const payloadHash = hash(JSON.stringify(payload)); + return this.db.transaction(() => { + const row = this.db + .select() + .from(s.idempotency) + .where( + and( + eq(s.idempotency.guestId, g.id), + eq(s.idempotency.operation, operation), + eq(s.idempotency.key, key!), + ), + ) + .get(); + if (row) { + if (row.payloadHash !== payloadHash) + fail(409, 'IDEMPOTENCY_CONFLICT', '같은 요청 키의 내용이 달라졌습니다.'); + return JSON.parse(row.response) as T; + } + const result = fn(); + this.db + .insert(s.idempotency) + .values({ + id: randomUUID(), + guestId: g.id, + operation, + key: key!, + payloadHash, + response: JSON.stringify(result), + createdAt: Date.now(), + }) + .run(); + return result; + }); + } + createRequest( + g: Guest, + input: { + targetDeviceId: string; + kind: 'url' | 'file'; + byteSize: number; + count: number; + files?: { name: string; size: number; type: string }[]; + }, + ip: string, + storedSource = false, + ) { + this.cleanup(); + const target = this.deviceGuest(input.targetDeviceId); + if ( + !target || + target.guestId === g.id || + !this.device(g.id).discoverable || + !this.canReceive(g.id, target.guestId) + ) + fail(403, 'TARGET_UNAVAILABLE', '이 기기로 보낼 수 없습니다.'); + if ( + (input.kind !== 'file' && input.byteSize > this.cfg.maxUrlBytes) || + input.count > this.cfg.maxItems || + input.files?.some((f) => f.size > this.cfg.maxFileBytes) + ) + fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.'); + this.limiter.hit(`request-ip:${ip}`, this.cfg.ipRate); + this.limiter.hit(`request-guest:${g.id}`, this.cfg.sessionRate); + this.limiter.hit(`request-pair:${g.id}:${target!.guestId}`, this.cfg.pairRate); + const rejected = this.db + .select() + .from(s.requests) + .where( + and( + eq(s.requests.senderGuestId, g.id), + eq(s.requests.recipientGuestId, target!.guestId), + eq(s.requests.status, 'rejected'), + gt(s.requests.rejectedAt, Date.now() - this.cfg.rejectCooldownMs), + ), + ) + .get(); + if (rejected) + fail(429, 'REJECTION_COOLDOWN', '상대가 거절했습니다. 잠시 후 다시 시도해 주세요.'); + const active = this.db + .select() + .from(s.requests) + .innerJoin(s.batches, eq(s.requests.id, s.batches.requestId)) + .where( + and( + eq(s.requests.senderGuestId, g.id), + inArray(s.requests.status, ['pending', 'accepted']), + inArray(s.batches.status, ['waiting', 'uploading']), + ), + ) + .get(); + if (active) fail(409, 'TRANSFER_ACTIVE', '진행 중인 전송을 먼저 완료하거나 취소해 주세요.'); + const now = Date.now(), + id = randomUUID(); + this.db + .insert(s.requests) + .values({ + id, + senderGuestId: g.id, + recipientGuestId: target!.guestId, + kind: input.kind, + byteSize: input.byteSize, + count: input.count, + manifest: input.files ? JSON.stringify(input.files) : null, + status: 'pending', + createdAt: now, + expiresAt: now + this.cfg.requestSeconds * 1000, + }) + .run(); + this.db.insert(s.batches).values({ id: randomUUID(), requestId: id, status: 'waiting' }).run(); + if (input.files && !storedSource) this.storage.createManifest(g, id, input.files); + return { requestId: id }; + } + request(g: Guest, id: string) { + const row = this.db + .select() + .from(s.requests) + .where( + and( + eq(s.requests.id, id), + or(eq(s.requests.senderGuestId, g.id), eq(s.requests.recipientGuestId, g.id)), + ), + ) + .get(); + if (!row) fail(404, 'REQUEST_NOT_FOUND', '전송 요청을 찾을 수 없습니다.'); + return row! as RequestRow & { senderGuestId: string; recipientGuestId: string }; + } + accept(g: Guest, id: string) { + const row = this.request(g, id); + if (row.recipientGuestId !== g.id) fail(403, 'FORBIDDEN', '수신 기기만 수락할 수 있습니다.'); + if (row.status !== 'pending' || row.expiresAt <= Date.now()) + fail(409, 'REQUEST_STATE', '수락할 수 없는 요청입니다.'); + if (!this.canReceive(row.senderGuestId, g.id)) + fail(409, 'TARGET_UNAVAILABLE', '현재 연결 상태에서 수락할 수 없습니다.'); + if (row.kind === 'file' && !this.vault.source(id)) { + const sender = this.db + .select() + .from(s.guests) + .where(eq(s.guests.id, row.senderGuestId)) + .get()!; + this.storage.reserve(sender, id); + } + const now = Date.now(); + this.db + .update(s.requests) + .set({ + status: 'accepted', + acceptedAt: now, + startBy: now + this.cfg.uploadStartHours * 3600000, + }) + .where(eq(s.requests.id, id)) + .run(); + return { requestId: id }; + } + reject(g: Guest, id: string) { + const row = this.request(g, id); + if (row.recipientGuestId !== g.id) fail(403, 'FORBIDDEN', '수신 기기만 거절할 수 있습니다.'); + if (row.status !== 'pending' || row.expiresAt <= Date.now()) + fail(409, 'REQUEST_STATE', '이미 처리된 요청입니다.'); + this.db + .update(s.requests) + .set({ status: 'rejected', rejectedAt: Date.now() }) + .where(eq(s.requests.id, id)) + .run(); + } + cancel(g: Guest, id: string) { + const row = this.request(g, id); + if (row.senderGuestId !== g.id) fail(403, 'FORBIDDEN', '발신 기기만 취소할 수 있습니다.'); + if (!['pending', 'accepted'].includes(row.status)) + fail(409, 'REQUEST_STATE', '취소할 수 없는 요청입니다.'); + const batch = this.db.select().from(s.batches).where(eq(s.batches.requestId, id)).get()!; + if (batch.status === 'stored') + fail(409, 'TRANSFER_STORED', '완료한 전송은 회수할 수 없습니다.'); + this.storage.release(id); + this.db.update(s.requests).set({ status: 'cancelled' }).where(eq(s.requests.id, id)).run(); + this.db.update(s.batches).set({ status: 'failed' }).where(eq(s.batches.id, batch.id)).run(); + } + block(g: Guest, id: string) { + const row = this.request(g, id); + if (row.recipientGuestId !== g.id) fail(403, 'FORBIDDEN', '수신 기기만 차단할 수 있습니다.'); + this.db.transaction(() => { + this.db + .insert(s.blocks) + .values({ id: randomUUID(), guestId: g.id, targetGuestId: row.senderGuestId }) + .onConflictDoNothing() + .run(); + this.db + .update(s.requests) + .set({ status: 'rejected', rejectedAt: Date.now() }) + .where( + and( + eq(s.requests.senderGuestId, row.senderGuestId), + eq(s.requests.recipientGuestId, g.id), + eq(s.requests.status, 'pending'), + ), + ) + .run(); + }); + this.storage.release(id); + this.notifyRequests(); + this.refresh(); + } + complete(g: Guest, id: string, input: { urls: string[] }) { + const row = this.request(g, id); + if (row.kind === 'file') + fail(400, 'FILE_UPLOAD_REQUIRED', '파일은 업로드 API를 사용해 주세요.'); + if (row.senderGuestId !== g.id) fail(403, 'FORBIDDEN', '발신 기기만 전송할 수 있습니다.'); + if ( + row.status !== 'accepted' || + !row.startBy || + row.startBy <= Date.now() || + this.isBlocked(g.id, row.recipientGuestId) + ) + fail(409, 'REQUEST_STATE', '수락된 유효한 요청이 필요합니다.'); + const batch = this.db.select().from(s.batches).where(eq(s.batches.requestId, id)).get()!; + if (batch.status !== 'waiting') fail(409, 'TRANSFER_STATE', '이미 처리된 전송입니다.'); + if (row.kind !== 'url') + fail(400, 'TYPE_UNSUPPORTED', '파일과 HTTP/HTTPS 링크만 보낼 수 있습니다.'); + const parsed = parseUrls(input.urls.join('\n')); + if (parsed.invalid.length || parsed.urls.length !== input.urls.length) + fail(400, 'INVALID_URL', 'HTTP 또는 HTTPS 링크만 보낼 수 있습니다.'); + const values = parsed.urls; + const bytes = Buffer.byteLength(values.join('\n'), 'utf8'); + if (bytes !== row.byteSize || values.length !== row.count || bytes > this.cfg.maxUrlBytes) + fail(409, 'METADATA_MISMATCH', '내용 크기나 개수가 바뀌었습니다. 새 요청을 보내 주세요.'); + const now = Date.now(); + for (const value of values) { + const itemId = randomUUID(); + this.db + .insert(s.items) + .values({ + id: itemId, + batchId: batch.id, + kind: row.kind, + + url: value, + createdAt: now, + expiresAt: now + this.cfg.urlRetentionDays * day, + }) + .run(); + this.accounts.createAccess(row, itemId); + } + this.db + .update(s.batches) + .set({ status: 'stored', storedAt: now }) + .where(eq(s.batches.id, batch.id)) + .run(); + return { requestId: id, batchId: batch.id }; + } + listRequests(g: Guest): TransferRequest[] { + this.cleanup(); + return this.db + .select({ request: s.requests, batch: s.batches }) + .from(s.requests) + .innerJoin(s.batches, eq(s.requests.id, s.batches.requestId)) + .where( + and( + inArray(s.requests.kind, ['url', 'file']), + gt(s.requests.createdAt, Date.now() - 7 * day), + or(eq(s.requests.senderGuestId, g.id), eq(s.requests.recipientGuestId, g.id)), + ), + ) + .orderBy(desc(s.requests.createdAt), desc(s.requests.id)) + .limit(100) + .all() + .map(({ request: r, batch: b }) => ({ + id: r.id, + batchId: b.id, + senderDevice: this.device(r.senderGuestId!), + recipientDevice: this.device(r.recipientGuestId!), + kind: r.kind as 'url' | 'file', + byteSize: r.byteSize, + count: r.count, + status: r.status, + transferStatus: b.status, + createdAt: r.createdAt, + expiresAt: r.expiresAt, + startBy: r.startBy, + files: + r.kind === 'file' + ? (this.vault.source(r.id) ? JSON.parse(r.manifest!) : this.storage.fileRows(r.id)).map( + (f: any) => ({ + id: f.id, + name: f.name, + size: f.size, + type: f.type, + status: f.status, + }), + ) + : undefined, + direction: r.senderGuestId === g.id ? 'sent' : 'received', + })); + } + listItems(g: Guest, cursor?: string, limit = 30) { + let after: { createdAt: number; id: string } | undefined; + if (cursor) { + try { + after = JSON.parse(Buffer.from(cursor, 'base64url').toString()); + if (!after || !Number.isSafeInteger(after.createdAt) || typeof after.id !== 'string') + throw Error(); + } catch { + fail(400, 'INVALID_CURSOR', '목록 커서가 올바르지 않습니다.'); + } + } + const rows = this.db + .select({ access: s.access, item: s.items, request: s.requests }) + .from(s.access) + .innerJoin(s.items, eq(s.access.itemId, s.items.id)) + .innerJoin(s.batches, eq(s.items.batchId, s.batches.id)) + .innerJoin(s.requests, eq(s.batches.requestId, s.requests.id)) + .where( + and( + eq(s.access.guestId, g.id), + inArray(s.items.kind, ['url', 'file']), + isNull(s.access.deletedAt), + gt(s.items.expiresAt, Date.now()), + after + ? or( + lt(s.items.createdAt, after.createdAt), + and(eq(s.items.createdAt, after.createdAt), lt(s.access.id, after.id)), + ) + : undefined, + ), + ) + .orderBy(desc(s.items.createdAt), desc(s.access.id)) + .limit(limit + 1) + .all(); + const result: Item[] = rows.slice(0, limit).map((r) => { + const peer = this.device( + (r.access.role === 'sender' ? r.request.recipientGuestId : r.request.senderGuestId)!, + ); + const recipient = this.db + .select() + .from(s.access) + .where(and(eq(s.access.itemId, r.item.id), eq(s.access.role, 'recipient'))) + .get(); + return { + accessId: r.access.id, + id: r.item.id, + batchId: r.item.batchId, + kind: r.item.kind as 'url' | 'file', + url: r.item.url, + file: r.item.fileId + ? (() => { + const f = this.db.select().from(s.files).where(eq(s.files.id, r.item.fileId!)).get()!; + return { id: f.id, name: f.name, size: f.size, type: f.type }; + })() + : undefined, + role: r.access.role, + read: r.access.read, + peerName: peer.name, + peerCode: peer.code, + createdAt: r.item.createdAt, + expiresAt: r.item.expiresAt, + recipientRead: recipient?.read ?? false, + }; + }); + const last = result.at(-1); + return { + items: result, + nextCursor: + rows.length > limit && last + ? Buffer.from(JSON.stringify({ createdAt: last.createdAt, id: last.accessId })).toString( + 'base64url', + ) + : null, + }; + } + accessDetail(g: Guest, id: string) { + const row = this.db + .select({ access: s.access, item: s.items }) + .from(s.access) + .innerJoin(s.items, eq(s.access.itemId, s.items.id)) + .where( + and( + eq(s.access.id, id), + eq(s.access.guestId, g.id), + isNull(s.access.deletedAt), + gt(s.items.expiresAt, Date.now()), + ), + ) + .get(); + if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.'); + return row!; + } + read(g: Guest, id: string, value: boolean) { + const row = this.db + .select({ access: s.access, item: s.items }) + .from(s.access) + .innerJoin(s.items, eq(s.access.itemId, s.items.id)) + .where( + and( + eq(s.access.id, id), + eq(s.access.guestId, g.id), + isNull(s.access.deletedAt), + gt(s.items.expiresAt, Date.now()), + ), + ) + .get(); + if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.'); + this.db + .update(s.access) + .set({ read: value }) + .where(and(eq(s.access.itemId, row!.item.id), eq(s.access.role, row!.access.role))) + .run(); + const all = this.db.select().from(s.access).where(eq(s.access.itemId, row!.item.id)).all(); + for (const a of all) { + if (a.guestId) this.presence.emit(a.guestId, 'access:updated', {}); + if (a.userId) this.accounts.notify(a.userId); + } + } + notifyRequests() { + for (const id of this.presence.ids()) this.presence.emit(id, 'request:updated', {}); + } + notifyTransfer(id: string) { + const row = this.db.select().from(s.requests).where(eq(s.requests.id, id)).get(); + if (row?.senderUserId) this.accounts.notify(row.senderUserId); + if (row?.recipientUserId) this.accounts.notify(row.recipientUserId); + const receipt = this.db + .select() + .from(s.receiptAccounts) + .where(eq(s.receiptAccounts.requestId, id)) + .get(); + if (receipt) this.accounts.notify(receipt.userId); + if (row) + for (const g of [row.senderGuestId, row.recipientGuestId]) { + if (!g) continue; + this.presence.emit(g, 'request:updated', {}); + this.presence.emit(g, 'transfer:updated', {}); + } + } + cleanup() { + const now = Date.now(); + const pending = this.db + .select() + .from(s.requests) + .where(and(eq(s.requests.status, 'pending'), lt(s.requests.expiresAt, now))) + .all(); + const approved = this.db + .select({ request: s.requests, batch: s.batches }) + .from(s.requests) + .innerJoin(s.batches, eq(s.requests.id, s.batches.requestId)) + .where( + and( + eq(s.requests.status, 'accepted'), + lt(s.requests.startBy, now), + inArray(s.batches.status, ['waiting', 'uploading']), + ), + ) + .all(); + this.db.transaction(() => { + for (const r of pending) + this.db.update(s.requests).set({ status: 'expired' }).where(eq(s.requests.id, r.id)).run(); + for (const r of approved) { + this.storage.release(r.request.id); + this.db + .update(s.requests) + .set({ status: 'expired' }) + .where(eq(s.requests.id, r.request.id)) + .run(); + this.db + .update(s.batches) + .set({ status: 'failed' }) + .where(eq(s.batches.id, r.batch.id)) + .run(); + } + const dead = this.db + .select({ id: s.items.id }) + .from(s.items) + .where( + sql`NOT EXISTS (SELECT 1 FROM item_access a WHERE a.itemId = ${s.items.id} AND (a.deletedAt IS NULL OR a.deletedAt > ${now - this.cfg.undoSeconds * 1000}) AND ${s.items.expiresAt} > ${now})`, + ) + .all(); + for (const item of dead) { + const accesses = this.db.select().from(s.access).where(eq(s.access.itemId, item.id)).all(); + for (const access of accesses) { + for (const source of this.db + .select() + .from(s.vaultTransfers) + .where(eq(s.vaultTransfers.accessId, access.id)) + .all()) { + const batch = this.db + .select() + .from(s.batches) + .where(eq(s.batches.requestId, source.requestId)) + .get()!; + if (batch.status !== 'stored') { + this.db + .update(s.requests) + .set({ status: 'expired' }) + .where(eq(s.requests.id, source.requestId)) + .run(); + this.db + .update(s.batches) + .set({ status: 'failed' }) + .where(eq(s.batches.id, batch.id)) + .run(); + } + } + this.db.delete(s.vaultTransfers).where(eq(s.vaultTransfers.accessId, access.id)).run(); + } + this.db.delete(s.access).where(eq(s.access.itemId, item.id)).run(); + this.db.delete(s.items).where(eq(s.items.id, item.id)).run(); + } + this.db.run( + sql`UPDATE transfer_requests SET note = NULL, manifest = NULL WHERE createdAt < ${now - 7 * day} AND NOT EXISTS (SELECT 1 FROM transfer_batches b INNER JOIN items i ON i.batchId = b.id WHERE b.requestId = transfer_requests.id AND i.expiresAt > ${now})`, + ); + const expiredSpaces = this.db + .select() + .from(s.spaces) + .where(and(isNull(s.spaces.closedAt), lt(s.spaces.expiresAt, now))) + .all(); + for (const space of expiredSpaces) { + const guests = this.db + .select({ guestId: s.devices.guestId }) + .from(s.members) + .innerJoin(s.devices, eq(s.members.deviceId, s.devices.id)) + .where(eq(s.members.spaceId, space.id)) + .all() + .map((member) => member.guestId); + this.db.update(s.spaces).set({ closedAt: now }).where(eq(s.spaces.id, space.id)).run(); + this.db + .update(s.members) + .set({ status: 'removed' }) + .where(eq(s.members.spaceId, space.id)) + .run(); + this.restrictTransfers(guests); + } + if (expiredSpaces.length) this.refresh(); + for (const guestId of this.presence.ids()) { + const guest = this.db.select().from(s.guests).where(eq(s.guests.id, guestId)).get(); + if (!guest || guest.revokedAt || guest.expiresAt <= now) this.presence.revoke(guestId); + } + this.db + .delete(s.idempotency) + .where(lt(s.idempotency.createdAt, now - 30 * day)) + .run(); + }); + if (pending.length || approved.length) this.notifyRequests(); + this.limiter.prune(); + } +} diff --git a/apps/server/src/storage.ts b/apps/server/src/storage.ts new file mode 100644 index 0000000..a0eeb7a --- /dev/null +++ b/apps/server/src/storage.ts @@ -0,0 +1,596 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdirSync, statfsSync, existsSync } from 'node:fs'; +import { createWriteStream, createReadStream } from 'node:fs'; +import { rename, unlink, readdir, stat, open } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { and, eq, gt, isNull, inArray, sql, or } from 'drizzle-orm'; +import sharp from 'sharp'; +import type { FastifyRequest } from 'fastify'; +import type { Guest, Service } from './service.js'; +import * as s from './db/schema.js'; +import { ApiError, fail } from './security.js'; +export type StorageSubject = { id: string; kind?: 'user'; sessionId?: string }; +export class Storage { + private active = new Map< + string, + { guestId: string; controller: AbortController; fileId: string; sessionId?: string } + >(); + private cleaning = false; + private cleanupPromise: Promise | undefined; + readonly temp: string; + readonly originals: string; + constructor(readonly service: Service) { + this.temp = resolve(service.cfg.uploadDir, 'temporary'); + this.originals = resolve(service.cfg.uploadDir, 'originals'); + mkdirSync(this.temp, { recursive: true, mode: 0o700 }); + mkdirSync(this.originals, { recursive: true, mode: 0o700 }); + } + private request(g: StorageSubject, id: string) { + return g.kind === 'user' + ? this.service.accounts.request(g.id, id) + : this.service.request(g as Guest, id); + } + private access(g: StorageSubject, id: string) { + return g.kind === 'user' + ? this.service.accounts.access(g.id, id) + : this.service.accessDetail(g as Guest, id); + } + private blocked(g: StorageSubject, row: ReturnType) { + return g.kind === 'user' + ? !this.service.accounts.canTransfer(row) + : this.service.isBlocked(g.id, row.recipientGuestId!); + } + private isSender(g: StorageSubject, row: ReturnType) { + return g.kind === 'user' ? row.senderUserId === g.id : row.senderGuestId === g.id; + } + fileRows(id: string) { + return this.service.db.select().from(s.files).where(eq(s.files.requestId, id)).all(); + } + createManifest( + g: StorageSubject, + requestId: string, + manifest: { name: string; size: number; type: string }[], + ) { + for (const [position, file] of manifest.entries()) + this.service.db + .insert(s.files) + .values({ + id: randomUUID(), + requestId, + ownerGuestId: g.kind === 'user' ? null : g.id, + ownerUserId: g.kind === 'user' ? g.id : null, + position, + name: file.name, + size: file.size, + type: file.type, + storageKey: randomUUID(), + status: 'pending', + updatedAt: Date.now(), + }) + .run(); + } + private used(guestId?: string) { + return this.service.db + .select({ bytes: sql`coalesce(sum(${s.files.size}),0)` }) + .from(s.files) + .where( + and( + inArray(s.files.status, ['stored', 'cleaning']), + sql`${s.files.ownerGuestId} IS NOT NULL`, + guestId ? eq(s.files.ownerGuestId, guestId) : undefined, + ), + ) + .get()!.bytes; + } + private reserved(guestId?: string) { + return this.service.db + .select({ bytes: sql`coalesce(sum(${s.reservations.bytes}),0)` }) + .from(s.reservations) + .where( + and( + gt(s.reservations.expiresAt, Date.now()), + sql`${s.reservations.guestId} IS NOT NULL`, + guestId ? eq(s.reservations.guestId, guestId) : undefined, + ), + ) + .get()!.bytes; + } + reserve(g: StorageSubject, requestId: string) { + const { db, cfg } = this.service; + const files = this.fileRows(requestId).filter((f) => f.status !== 'stored'); + const missing = files.filter( + (file) => + !db + .select() + .from(s.reservations) + .where(and(eq(s.reservations.fileId, file.id), gt(s.reservations.expiresAt, Date.now()))) + .get(), + ); + const total = missing.reduce((sum, file) => sum + file.size, 0); + if (g.kind === 'user') { + const reserved = db + .select({ bytes: sql`coalesce(sum(${s.reservations.bytes}),0)` }) + .from(s.reservations) + .where(and(eq(s.reservations.userId, g.id), gt(s.reservations.expiresAt, Date.now()))) + .get()!.bytes; + this.service.accounts.ensureQuota(g.id, reserved + total); + } + if (g.kind !== 'user' && this.used(g.id) + this.reserved(g.id) + total > cfg.guestQuota) + fail(413, 'GUEST_QUOTA', '이번 기기의 저장 용량을 초과했습니다.'); + if (g.kind !== 'user' && this.used() + this.reserved() + total > cfg.totalGuestQuota) + fail(507, 'TOTAL_QUOTA', '현재 서버의 익명 저장 공간이 부족합니다.'); + const allReserved = db + .select({ bytes: sql`coalesce(sum(${s.reservations.bytes}),0)` }) + .from(s.reservations) + .where(gt(s.reservations.expiresAt, Date.now())) + .get()!.bytes; + const disk = statfsSync(this.originals, { bigint: true }); + if (BigInt(allReserved + total + cfg.minFreeBytes) > disk.bavail * disk.bsize) + fail(507, 'DISK_FULL', '서버의 여유 공간이 부족합니다.'); + for (const file of missing) + db.insert(s.reservations) + .values({ + id: randomUUID(), + fileId: file.id, + guestId: g.kind === 'user' ? null : g.id, + userId: g.kind === 'user' ? g.id : null, + bytes: file.size, + expiresAt: Date.now() + cfg.orphanHours * 3600000, + }) + .onConflictDoUpdate({ + target: s.reservations.fileId, + set: { expiresAt: Date.now() + cfg.orphanHours * 3600000 }, + }) + .run(); + } + release(requestId: string) { + const db = this.service.db; + for (const file of this.fileRows(requestId)) { + for (const [uploadId, upload] of this.active) + if (upload.fileId === file.id) { + upload.controller.abort(); + this.active.delete(uploadId); + } + db.delete(s.reservations).where(eq(s.reservations.fileId, file.id)).run(); + if (file.status !== 'stored') + db.update(s.files) + .set({ status: 'failed', updatedAt: Date.now() }) + .where(eq(s.files.id, file.id)) + .run(); + } + } + async upload( + g: StorageSubject, + requestId: string, + position: number, + request: FastifyRequest, + key: string, + ) { + const { db, cfg } = this.service; + let file: typeof s.files.$inferSelect; + let uploadId: string; + const existing = this.fileRows(requestId).find((f) => f.position === position); + if (existing?.status === 'stored') { + const row = this.request(g, requestId); + if (!this.isSender(g, row)) fail(403, 'UPLOAD_FORBIDDEN', '발신자만 업로드할 수 있습니다.'); + if (existing.uploadKey !== key) fail(409, 'FILE_STORED', '이미 저장된 파일입니다.'); + const checksum = createHash('sha256'); + let bytes = 0, + count = 0; + for await (const part of request.parts({ + limits: { files: 1, fields: 0, parts: 1, fileSize: cfg.maxFileBytes + 1 }, + })) { + if (part.type !== 'file') + throw new ApiError(400, 'MULTIPART_INVALID', '파일 한 개가 필요합니다.'); + count++; + if (part.filename !== existing.name) + fail(409, 'IDEMPOTENCY_CONFLICT', '같은 키의 파일 내용이 다릅니다.'); + for await (const chunk of part.file) { + bytes += chunk.length; + if (bytes > existing.size) + fail(409, 'IDEMPOTENCY_CONFLICT', '같은 키의 파일 내용이 다릅니다.'); + checksum.update(chunk); + } + } + if (count !== 1 || bytes !== existing.size || checksum.digest('hex') !== existing.checksum) + fail(409, 'IDEMPOTENCY_CONFLICT', '같은 키의 파일 내용이 다릅니다.'); + return { uploadId: existing.uploadId!, fileId: existing.id, status: 'stored' }; + } + db.transaction(() => { + const row = this.request(g, requestId); + if (row.kind !== 'file' || !this.isSender(g, row)) + fail(403, 'UPLOAD_FORBIDDEN', '이 요청에 파일을 올릴 수 없습니다.'); + if ( + row.status !== 'accepted' || + !row.startBy || + row.startBy <= Date.now() || + this.blocked(g, row) + ) + fail(409, 'ACCEPTANCE_REQUIRED', '수락된 유효한 요청이 필요합니다.'); + file = this.fileRows(requestId).find((f) => f.position === position)!; + if (!file) fail(400, 'FILE_NOT_REQUESTED', '요청에 없는 파일입니다.'); + if (file.status === 'stored') fail(409, 'FILE_STORED', '이미 저장된 파일입니다.'); + if ( + file.status === 'uploading' || + [...this.active.values()].filter((v) => v.guestId === `${g.kind ?? 'guest'}:${g.id}`) + .length >= (g.kind === 'user' ? 2 : 1) + ) + fail(409, 'UPLOAD_ACTIVE', '진행 중인 업로드를 먼저 끝내 주세요.'); + this.reserve(g, requestId); + uploadId = randomUUID(); + db.update(s.files) + .set({ status: 'uploading', uploadId, uploadKey: key, updatedAt: Date.now() }) + .where(eq(s.files.id, file.id)) + .run(); + db.update(s.batches) + .set({ status: 'uploading' }) + .where(eq(s.batches.requestId, requestId)) + .run(); + }); + const controller = new AbortController(), + tempPath = resolve(this.temp, uploadId!); + this.active.set(uploadId!, { + guestId: `${g.kind ?? 'guest'}:${g.id}`, + controller, + sessionId: g.sessionId, + fileId: file!.id, + }); + request.raw.once('aborted', () => controller.abort()); + this.service.notifyTransfer(requestId); + try { + let parts = 0; + let bytes = 0; + const checksum = createHash('sha256'); + for await (const part of request.parts({ + limits: { files: 1, fields: 0, parts: 1, fileSize: cfg.maxFileBytes + 1 }, + })) { + if (part.type !== 'file') + throw new ApiError(400, 'MULTIPART_INVALID', '파일 한 개만 업로드해 주세요.'); + parts++; + if (part.filename !== file!.name) + fail(409, 'METADATA_MISMATCH', '요청한 파일 이름과 다릅니다.'); + const counter = new Transform({ + transform(chunk, _encoding, callback) { + bytes += chunk.length; + if (bytes > file!.size || bytes > cfg.maxFileBytes) + return callback( + new ApiError(413, 'FILE_SIZE_MISMATCH', '파일 크기가 요청과 다릅니다.'), + ); + checksum.update(chunk); + callback(null, chunk); + }, + }); + await pipeline( + part.file, + counter, + createWriteStream(tempPath, { flags: 'wx', mode: 0o600 }), + { signal: controller.signal }, + ); + if (part.file.truncated || bytes !== file!.size) + fail(409, 'FILE_SIZE_MISMATCH', '파일 크기가 요청과 다릅니다.'); + } + if (parts !== 1) fail(400, 'MULTIPART_INVALID', '파일 한 개가 필요합니다.'); + const originalPath = resolve(this.originals, file!.storageKey); + await rename(tempPath, originalPath); + try { + db.transaction(() => { + if (g.kind === 'user') { + const session = g.sessionId + ? db + .select() + .from(s.accountSessions) + .where(eq(s.accountSessions.id, g.sessionId)) + .get() + : null; + if ( + !session || + session.userId !== g.id || + session.revokedAt || + session.expiresAt <= Date.now() + ) + fail(401, 'SESSION_REVOKED', '계정 세션이 종료되었습니다.'); + } + const row = this.request(g, requestId), + current = db.select().from(s.files).where(eq(s.files.id, file!.id)).get(); + if ( + row.status !== 'accepted' || + this.blocked(g, row) || + current?.uploadId !== uploadId || + current.status !== 'uploading' + ) + fail(409, 'UPLOAD_CANCELLED', '전송이 취소되었거나 권한이 변경되었습니다.'); + const batch = db + .select() + .from(s.batches) + .where(eq(s.batches.requestId, requestId)) + .get()!, + now = Date.now(), + itemId = randomUUID(); + db.update(s.files) + .set({ status: 'stored', checksum: checksum.digest('hex'), updatedAt: now }) + .where(eq(s.files.id, file!.id)) + .run(); + db.delete(s.reservations).where(eq(s.reservations.fileId, file!.id)).run(); + db.insert(s.items) + .values({ + id: itemId, + batchId: batch.id, + kind: 'file', + fileId: file!.id, + createdAt: now, + expiresAt: now + cfg.fileRetentionDays * 86400000, + }) + .run(); + this.service.accounts.createAccess(row, itemId); + const all = this.fileRows(requestId); + db.update(s.batches) + .set({ + status: all.every((f) => f.status === 'stored') ? 'stored' : 'waiting', + storedAt: now, + }) + .where(eq(s.batches.id, batch.id)) + .run(); + }); + } catch (error) { + await unlink(originalPath).catch(() => {}); + throw error; + } + return { uploadId: uploadId!, fileId: file!.id, status: 'stored' }; + } catch (error) { + await unlink(tempPath).catch(() => {}); + db.delete(s.reservations).where(eq(s.reservations.fileId, file!.id)).run(); + db.update(s.files) + .set({ status: 'failed', updatedAt: Date.now() }) + .where(and(eq(s.files.id, file!.id), eq(s.files.uploadId, uploadId!))) + .run(); + db.update(s.batches) + .set({ status: 'waiting' }) + .where(and(eq(s.batches.requestId, requestId), eq(s.batches.status, 'uploading'))) + .run(); + if (error instanceof ApiError) throw error; + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOSPC') + fail(507, 'DISK_FULL', '서버 저장 공간이 부족합니다.'); + fail(400, 'UPLOAD_FAILED', '업로드가 중단되었습니다. 파일을 다시 선택해 재시도해 주세요.'); + } finally { + this.active.delete(uploadId!); + this.service.notifyTransfer(requestId); + } + } + cancel(g: StorageSubject, id: string) { + const file = this.service.db + .select() + .from(s.files) + .where( + and( + eq(s.files.uploadId, id), + g.kind === 'user' ? eq(s.files.ownerUserId, g.id) : eq(s.files.ownerGuestId, g.id), + ), + ) + .get(); + if (!file) fail(404, 'UPLOAD_NOT_FOUND', '업로드를 찾을 수 없습니다.'); + if (file!.status !== 'uploading') + fail(409, 'UPLOAD_NOT_ACTIVE', '진행 중인 업로드만 취소할 수 있습니다.'); + this.active.get(id)?.controller.abort(); + this.service.db + .update(s.files) + .set({ status: 'failed', updatedAt: Date.now() }) + .where(eq(s.files.id, file!.id)) + .run(); + } + download(g: StorageSubject, accessId: string) { + const row = this.access(g, accessId); + if (row.item.kind !== 'file' || !row.item.fileId) + fail(404, 'FILE_NOT_FOUND', '파일을 찾을 수 없습니다.'); + const file = this.service.db + .select() + .from(s.files) + .where(and(eq(s.files.id, row.item.fileId!), eq(s.files.status, 'stored'))) + .get(); + if (!file || !existsSync(resolve(this.originals, file.storageKey))) + fail(404, 'FILE_NOT_FOUND', '파일을 찾을 수 없습니다.'); + return { file: file!, path: resolve(this.originals, file!.storageKey) }; + } + async preview(g: StorageSubject, id: string) { + const { path } = this.download(g, id); + try { + const handle = await open(path, 'r'); + const magic = Buffer.alloc(16); + try { + await handle.read(magic, 0, 16, 0); + } finally { + await handle.close(); + } + const raster = + magic.subarray(0, 3).equals(Buffer.from([255, 216, 255])) || + magic.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) || + /^GIF8[79]a/.test(magic.toString('ascii')) || + (magic.toString('ascii', 0, 4) === 'RIFF' && magic.toString('ascii', 8, 12) === 'WEBP') || + (magic.toString('ascii', 4, 8) === 'ftyp' && + ['avif', 'avis', 'heic', 'heix', 'mif1'].includes(magic.toString('ascii', 8, 12))); + if (!raster) throw Error(); + const image = sharp(path, { limitInputPixels: 40000000, animated: false }); + const metadata = await image.metadata(); + if (!['jpeg', 'png', 'webp', 'gif', 'avif', 'heif'].includes(metadata.format ?? '')) + throw Error(); + return await image + .resize(720, 720, { fit: 'inside', withoutEnlargement: true }) + .rotate() + .webp({ quality: 78 }) + .toBuffer(); + } catch { + fail(415, 'PREVIEW_UNSUPPORTED', '이 파일은 이미지 미리보기를 지원하지 않습니다.'); + } + } + deleteAccess(g: StorageSubject, ids: string[]) { + const { db, cfg } = this.service; + return db.transaction(() => { + const unique = [...new Set(ids)], + rows = unique.map((id) => this.access(g, id)); + const id = randomUUID(), + now = Date.now(); + db.insert(s.deletions) + .values({ + id, + guestId: g.kind === 'user' ? null : g.id, + userId: g.kind === 'user' ? g.id : null, + createdAt: now, + undoUntil: now + cfg.undoSeconds * 1000, + }) + .run(); + for (const row of rows) { + db.update(s.access).set({ deletedAt: now }).where(eq(s.access.id, row.access.id)).run(); + db.insert(s.deletionTargets) + .values({ id: randomUUID(), operationId: id, accessId: row.access.id }) + .run(); + } + return { operationId: id, undoUntil: now + cfg.undoSeconds * 1000 }; + }); + } + undo(g: StorageSubject, id: string) { + const { db } = this.service; + return db.transaction(() => { + const operation = db + .select() + .from(s.deletions) + .where( + and( + eq(s.deletions.id, id), + g.kind === 'user' ? eq(s.deletions.userId, g.id) : eq(s.deletions.guestId, g.id), + ), + ) + .get(); + if (!operation) fail(404, 'DELETE_NOT_FOUND', '삭제 작업을 찾을 수 없습니다.'); + if (operation!.restoredAt) return { restored: true }; + if (operation!.undoUntil <= Date.now()) + fail(409, 'UNDO_EXPIRED', '실행 취소 시간이 지났습니다.'); + const targets = db + .select() + .from(s.deletionTargets) + .where(eq(s.deletionTargets.operationId, id)) + .all(); + let count = 0; + for (const target of targets) { + const row = db + .select({ access: s.access, item: s.items }) + .from(s.access) + .innerJoin(s.items, eq(s.access.itemId, s.items.id)) + .where( + and( + eq(s.access.id, target.accessId), + g.kind === 'user' ? eq(s.access.userId, g.id) : eq(s.access.guestId, g.id), + eq(s.access.deletedAt, operation!.createdAt), + gt(s.items.expiresAt, Date.now()), + ), + ) + .get(); + if (row) { + db.update(s.access).set({ deletedAt: null }).where(eq(s.access.id, row.access.id)).run(); + count++; + } + } + db.update(s.deletions).set({ restoredAt: Date.now() }).where(eq(s.deletions.id, id)).run(); + return { restored: count > 0 }; + }); + } + cleanup(): Promise { + if (this.cleanupPromise) return this.cleanupPromise; + this.cleanupPromise = this.performCleanup().finally(() => { + this.cleanupPromise = undefined; + }); + return this.cleanupPromise; + } + private async performCleanup() { + if (this.cleaning) return; + this.cleaning = true; + try { + const { db, cfg } = this.service, + now = Date.now(); + const expired = db + .select() + .from(s.reservations) + .where(sql`${s.reservations.expiresAt} <= ${now}`) + .all(); + for (const r of expired) { + const file = db.select().from(s.files).where(eq(s.files.id, r.fileId)).get(); + if (file) this.release(file.requestId); + } + for (const file of db.select().from(s.files).where(eq(s.files.status, 'uploading')).all()) { + if (!file.uploadId || !this.active.has(file.uploadId)) { + db.update(s.files) + .set({ status: 'failed', updatedAt: now }) + .where(eq(s.files.id, file.id)) + .run(); + } + } + const all = db + .select() + .from(s.files) + .where(inArray(s.files.status, ['stored', 'cleaning'])) + .all(); + for (const file of all) { + const refs = db + .select({ id: s.items.id }) + .from(s.items) + .innerJoin(s.access, eq(s.access.itemId, s.items.id)) + .where( + and( + eq(s.items.fileId, file.id), + gt(s.items.expiresAt, now), + orLive(now, cfg.undoSeconds), + ), + ) + .all(); + if (refs.length) continue; + db.update(s.files).set({ status: 'cleaning' }).where(eq(s.files.id, file.id)).run(); + try { + await unlink(resolve(this.originals, file.storageKey)); + } catch (error) { + if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT') + continue; + } + db.update(s.files) + .set({ status: 'failed', checksum: null, updatedAt: now }) + .where(eq(s.files.id, file.id)) + .run(); + } + const validKeys = new Set( + db + .select() + .from(s.files) + .where(inArray(s.files.status, ['stored', 'cleaning'])) + .all() + .map((f) => f.storageKey), + ); + for (const name of await readdir(this.originals)) { + if (validKeys.has(name)) continue; + const path = resolve(this.originals, name), + metadata = await stat(path); + if (metadata.mtimeMs < now - cfg.orphanHours * 3600000) await unlink(path).catch(() => {}); + } + for (const name of await readdir(this.temp)) { + if (this.active.has(name)) continue; + const path = resolve(this.temp, name); + const metadata = await stat(path); + if (metadata.mtimeMs < now - cfg.orphanHours * 3600000) await unlink(path).catch(() => {}); + } + } finally { + this.cleaning = false; + } + } + abortSession(id: string) { + for (const upload of this.active.values()) + if (upload.sessionId === id) upload.controller.abort(); + } + abortUser(id: string) { + for (const upload of this.active.values()) + if (upload.guestId === `user:${id}`) upload.controller.abort(); + } + close() { + for (const upload of this.active.values()) upload.controller.abort(); + } +} +function orLive(now: number, undoSeconds: number) { + return sql`(${s.access.deletedAt} IS NULL OR ${s.access.deletedAt} > ${now - undoSeconds * 1000})`; +} diff --git a/apps/server/src/vault.js b/apps/server/src/vault.js new file mode 100644 index 0000000..ea277c0 --- /dev/null +++ b/apps/server/src/vault.js @@ -0,0 +1,141 @@ +import { randomUUID } from 'node:crypto'; +import { and, eq } from 'drizzle-orm'; +import * as s from './db/schema.js'; +import { fail } from './security.js'; + +export class Vault { + constructor(service) { + this.service = service; + } + bindReceipt(requestId, current) { + if (!current || !this.service.auth.serialize(current.user).verified) return; + this.service.db + .insert(s.receiptAccounts) + .values({ requestId, userId: current.user.id, sessionId: current.session.id }) + .onConflictDoNothing() + .run(); + } + receipt(request, itemId) { + const { db, auth } = this.service; + const binding = db + .select() + .from(s.receiptAccounts) + .where(eq(s.receiptAccounts.requestId, request.id)) + .get(); + if (!binding) return; + const session = db + .select() + .from(s.accountSessions) + .where(eq(s.accountSessions.id, binding.sessionId)) + .get(); + if ( + !session || + session.revokedAt || + session.expiresAt <= Date.now() || + session.userId !== binding.userId + ) + return; + if (!auth.serialize(auth.user(binding.userId)).verified) return; + db.insert(s.access) + .values({ id: randomUUID(), itemId, userId: binding.userId, role: 'recipient', read: false }) + .onConflictDoNothing() + .run(); + } + source(requestId) { + return this.service.db + .select() + .from(s.vaultTransfers) + .where(eq(s.vaultTransfers.requestId, requestId)) + .get(); + } + authorize(source) { + const { db, accounts } = this.service; + const session = db + .select() + .from(s.accountSessions) + .where(eq(s.accountSessions.id, source.sessionId)) + .get(); + if ( + !session || + session.userId !== source.userId || + session.revokedAt || + session.expiresAt <= Date.now() + ) + fail(401, 'SESSION_REVOKED', '보낸 계정의 세션이 종료되었습니다.'); + const row = accounts.access(source.userId, source.accessId); + if (!['self', 'recipient'].includes(row.access.role)) + fail(403, 'VAULT_REQUIRED', '보관함에 있는 자료만 보낼 수 있습니다.'); + if (row.item.fileId) { + const file = db.select().from(s.files).where(eq(s.files.id, row.item.fileId)).get(); + if (!file || file.status !== 'stored') + fail(404, 'FILE_NOT_FOUND', '파일이 없거나 만료되었습니다.'); + return { ...row, file }; + } + return row; + } + forward(guest, current, accessId, targetDeviceId, ip) { + const source = { userId: current.user.id, sessionId: current.session.id, accessId }; + const row = this.authorize(source); + const input = row.item.url + ? { targetDeviceId, kind: 'url', byteSize: Buffer.byteLength(row.item.url), count: 1 } + : { + targetDeviceId, + kind: 'file', + byteSize: row.file.size, + count: 1, + files: [ + { id: row.file.id, name: row.file.name, size: row.file.size, type: row.file.type }, + ], + }; + const result = this.service.createRequest(guest, input, ip, true); + this.service.db + .insert(s.vaultTransfers) + .values({ ...source, requestId: result.requestId }) + .run(); + return result; + } + deliver(requestId) { + const source = this.source(requestId); + if (!source) return; + const { db, accounts } = this.service; + const row = this.authorize(source); + const request = db.select().from(s.requests).where(eq(s.requests.id, requestId)).get(); + const batch = db.select().from(s.batches).where(eq(s.batches.requestId, requestId)).get(); + if (batch.status === 'stored') return; + if ( + request.status !== 'accepted' || + request.startBy <= Date.now() || + this.service.isBlocked(request.senderGuestId, request.recipientGuestId) + ) + fail(409, 'REQUEST_STATE', '유효한 수락이 필요합니다.'); + const now = Date.now(), + itemId = randomUUID(); + db.insert(s.items) + .values({ + id: itemId, + batchId: batch.id, + kind: row.item.kind, + url: row.item.url, + fileId: row.item.fileId, + createdAt: now, + expiresAt: now + 7 * 86400000, + }) + .run(); + accounts.createAccess(request, itemId); + db.update(s.batches) + .set({ status: 'stored', storedAt: now }) + .where(eq(s.batches.id, batch.id)) + .run(); + } + note(userId, accessId, note) { + const row = this.service.accounts.access(userId, accessId); + if (row.access.role !== 'self') + fail(403, 'VAULT_REQUIRED', '임시 보관함 자료만 수정할 수 있습니다.'); + this.service.db + .update(s.access) + .set({ note: note || null }) + .where(and(eq(s.access.id, accessId), eq(s.access.userId, userId))) + .run(); + this.service.accounts.notify(userId); + } +} diff --git a/apps/server/test/helpers.ts b/apps/server/test/helpers.ts new file mode 100644 index 0000000..a389bdb --- /dev/null +++ b/apps/server/test/helpers.ts @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { io, type Socket } from 'socket.io-client'; +import { eq } from 'drizzle-orm'; +import { createApp } from '../src/app.js'; +import { loadConfig } from '../src/config.js'; +import * as schema from '../src/db/schema.js'; +import { clientIp } from '../src/security.js'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +export function urlFor(value: string) { + return /^https?:\/\//.test(value) + ? value + .split('\n') + .map((url) => new URL(url).href) + .join('\n') + : `https://example.test/${encodeURIComponent(value)}`; +} +const origin = 'http://hop.test'; +type Client = { cookie: string; csrf: string; deviceId: string; socket?: Socket; ip: string }; +export async function fixture(discovery = false, mailer?: import('../src/auth.js').Mailer) { + const folder = await mkdtemp(join(tmpdir(), 'hop-api-')); + const cfg = { + ...loadConfig({ + APP_URL: origin, + DATABASE_PATH: ':memory:', + SESSION_SECRET: 's'.repeat(40), + DISCOVERY_SECRET: 'd'.repeat(40), + }), + discoveryEnabled: discovery, + uploadDir: join(folder, 'uploads'), + minFreeBytes: 0, + trustedProxies: ['127.0.0.1', '::1'], + offlineGraceMs: 20, + }; + const runtime = await createApp(cfg, { mailer }); + await runtime.app.listen({ port: 0, host: '127.0.0.1' }); + const address = runtime.app.server.address(); + assert(address && typeof address !== 'string'); + const url = `http://127.0.0.1:${address.port}`; + const sockets: Socket[] = []; + const call = async ( + c: Client | null, + path: string, + method = 'GET', + payload?: unknown, + key?: string, + headers: Record = {}, + ) => + runtime.app.inject({ + method: method as any, + url: `/api${path}`, + headers: { + origin, + ...(c ? { cookie: c.cookie, 'x-csrf-token': c.csrf, 'x-forwarded-for': c.ip } : {}), + ...(key ? { 'idempotency-key': key } : {}), + ...headers, + }, + payload, + }); + const create = async (ip = '203.0.113.1', online = true) => { + const response = await call(null, '/guest/session', 'POST', {}); + assert.equal(response.statusCode, 200); + const result = response.json(); + const c: Client = { + cookie: String(response.headers['set-cookie']).split(';')[0]!, + csrf: result.csrfToken, + deviceId: result.device.id, + ip, + }; + if (online) await connect(c); + return c; + }; + const connect = async (c: Client) => { + const socket = io(url, { + transports: ['websocket'], + reconnection: false, + auth: { csrfToken: c.csrf }, + extraHeaders: { Origin: origin, Cookie: c.cookie, 'X-Forwarded-For': c.ip }, + }); + sockets.push(socket); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(Error('socket connect timeout')), 3000); + socket.once('connect', () => { + clearTimeout(timer); + resolve(); + }); + socket.once('connect_error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); + c.socket = socket; + return socket; + }; + const invitations = new Map(); + const space = async (a: Client, b: Client, approvalRequired = false) => { + const current = (await call(a, '/spaces')).json().spaces; + let result = invitations.get(a.deviceId); + if (!result || !current.some((room: any) => room.id === result.space.id)) { + const response = await call(a, '/spaces', 'POST', { approvalRequired }); + assert.equal(response.statusCode, 200, response.body); + result = response.json(); + invitations.set(a.deviceId, result); + } + const join = await call(b, '/spaces/join', 'POST', { code: result.code }); + assert.equal(join.statusCode, 200); + return result; + }; + const request = async ( + a: Client, + b: Client, + text = '안녕하세요', + kind: 'url' = 'url', + count = 1, + key = randomUUID(), + ) => { + const r = await call( + a, + '/transfer-requests', + 'POST', + { targetDeviceId: b.deviceId, kind, byteSize: Buffer.byteLength(urlFor(text)), count }, + key, + ); + return r; + }; + const complete = async (a: Client, b: Client, text = '안녕하세요') => { + const r = await request(a, b, text); + assert.equal(r.statusCode, 200); + const id = r.json().requestId; + assert.equal( + (await call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, + 200, + ); + assert.equal( + (await call(a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor(text)] }, randomUUID())) + .statusCode, + 200, + ); + return id; + }; + const close = async () => { + for (const socket of sockets) socket.disconnect(); + await runtime.app.close(); + await rm(folder, { recursive: true, force: true }); + }; + return { ...runtime, cfg, url, call, create, connect, space, request, complete, close }; +} diff --git a/apps/server/test/mail.test.ts b/apps/server/test/mail.test.ts new file mode 100644 index 0000000..399fb94 --- /dev/null +++ b/apps/server/test/mail.test.ts @@ -0,0 +1,66 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createResendMailer } from '../src/mail.js'; +import { ApiError } from '../src/security.js'; + +test('Resend 키가 없으면 발송 모듈은 비활성', () => { + assert.equal(createResendMailer({ mailFrom: 'hop. ' }), undefined); +}); +test('Resend 발송 계약과 고정 중복 키: 키는 서버 Authorization에만 전달', async () => { + const calls: { url: unknown; options: RequestInit | undefined }[] = []; + const fakeFetch = (async (url: unknown, options: RequestInit) => { + calls.push({ url, options }); + return new Response(JSON.stringify({ id: 'test-message' }), { status: 200 }); + }) as typeof fetch; + const mailer = createResendMailer( + { resendApiKey: 're_test_fixture', mailFrom: 'hop. ' }, + fakeFetch, + )!; + await mailer({ + to: 'recipient@example.com', + subject: '인증', + text: 'test body', + idempotencyKey: 'auth-verify-fixture', + }); + assert.equal(calls.length, 1); + assert.equal(calls[0]!.url, 'https://api.resend.com/emails'); + const options = calls[0]!.options!, + headers = new Headers(options.headers); + assert.equal(headers.get('Authorization'), 'Bearer re_test_fixture'); + assert.equal(headers.get('Idempotency-Key'), 'auth-verify-fixture'); + assert.equal(options.redirect, 'error'); + assert(options.signal instanceof AbortSignal); + assert.deepEqual(JSON.parse(String(options.body)), { + from: 'hop. ', + to: ['recipient@example.com'], + subject: '인증', + text: 'test body', + }); + assert(!String(options.body).includes('re_test_fixture')); +}); +test('Resend 거절·타임아웃·잘못된 응답은 민감한 원문 없이 오류를 반환', async () => { + for (const fakeFetch of [ + async () => + new Response(JSON.stringify({ message: 'private provider response re_test_fixture' }), { + status: 401, + }), + async () => { + throw Error('private credential re_test_fixture'); + }, + async () => new Response('{}', { status: 200 }), + ]) { + const mailer = createResendMailer( + { resendApiKey: 're_test_fixture', mailFrom: 'hop. ' }, + fakeFetch as typeof fetch, + )!; + await assert.rejects( + mailer({ to: 'recipient@example.com', subject: '인증', text: 'test body' }), + (error) => + error instanceof ApiError && + error.code === 'MAIL_DELIVERY_FAILED' && + error.status === 503 && + !error.message.includes('re_test_fixture') && + !error.message.includes('private'), + ); + } +}); diff --git a/apps/server/test/migrations.test.ts b/apps/server/test/migrations.test.ts new file mode 100644 index 0000000..292fb8a --- /dev/null +++ b/apps/server/test/migrations.test.ts @@ -0,0 +1,74 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import Database from 'better-sqlite3'; +import { readMigrationFiles } from 'drizzle-orm/migrator'; +import { openDatabase } from '../src/db/index.js'; +test('사용 중 적용된 초기 계정 DB 버전을 후속 마이그레이션으로 보정하고 기록 보존', async () => { + const folder = await mkdtemp(join(tmpdir(), 'hop-legacy-')); + try { + const path = join(folder, 'legacy.sqlite'), + old = new Database(path), + migrations = readMigrationFiles({ + migrationsFolder: join(import.meta.dirname, '../drizzle'), + }); + old.exec( + 'CREATE TABLE __drizzle_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT,hash TEXT NOT NULL,created_at INTEGER)', + ); + for (const [index, migration] of migrations.slice(0, 5).entries()) { + for (let statement of migration.sql) { + if (index === 4 && statement.includes('CREATE TABLE `__new_deletion_operations`')) + statement = statement.replace('`guestId` text,', '`guestId` text NOT NULL,'); + old.exec(statement); + } + old + .prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)') + .run( + index === 4 + ? 'fb1af44cf19e4f77c50f07608ba046819dce40cae3925e0b632d1d9afa045068' + : migration.hash, + index === 4 ? 1791448938074 : migration.folderMillis, + ); + } + const now = Date.now(); + old + .prepare('INSERT INTO guest_sessions(id,tokenHash,expiresAt,createdAt) VALUES (?,?,?,?)') + .run('guest', 'hash', now + 86400000, now); + old + .prepare('INSERT INTO deletion_operations(id,guestId,createdAt,undoUntil) VALUES (?,?,?,?)') + .run('operation', 'guest', now, now + 5000); + old.close(); + const { sqlite } = openDatabase(path); + const info = sqlite.pragma('table_info(deletion_operations)') as { + name: string; + notnull: number; + }[]; + assert.equal(info.find((c) => c.name === 'guestId')!.notnull, 0); + assert.equal((sqlite.prepare('SELECT count(*) AS n FROM guest_sessions').get() as any).n, 1); + assert.equal( + (sqlite.prepare('SELECT guestId FROM deletion_operations WHERE id=?').get('operation') as any) + .guestId, + 'guest', + ); + assert.equal((sqlite.pragma('foreign_key_check') as any[]).length, 0); + assert.equal( + ( + sqlite + .prepare('SELECT count(*) AS n FROM __drizzle_migrations WHERE hash=?') + .get('fb1af44cf19e4f77c50f07608ba046819dce40cae3925e0b632d1d9afa045068') as any + ).n, + 1, + ); + sqlite.close(); + const again = openDatabase(path); + assert.equal( + (again.sqlite.prepare('SELECT count(*) AS n FROM guest_sessions').get() as any).n, + 1, + ); + again.sqlite.close(); + } finally { + await rm(folder, { recursive: true, force: true }); + } +}); diff --git a/apps/server/test/phase1.test.ts b/apps/server/test/phase1.test.ts new file mode 100644 index 0000000..e636d34 --- /dev/null +++ b/apps/server/test/phase1.test.ts @@ -0,0 +1,557 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { io, type Socket } from 'socket.io-client'; +import { eq } from 'drizzle-orm'; +import { createApp } from '../src/app.js'; +import { loadConfig } from '../src/config.js'; +import * as schema from '../src/db/schema.js'; +import { clientIp } from '../src/security.js'; +import { fixture, urlFor } from './helpers.js'; +test('익명 이름과 비밀 쿠키가 유지되고 공개 ID는 인증이 아니다', async () => { + const f = await fixture(); + try { + const a = await f.create(); + const changed = await f.call(a, '/device/me', 'PATCH', { name: '나의 Mac' }); + assert.equal(changed.statusCode, 200); + const again = await f.call(a, '/guest/session', 'POST', {}); + assert.equal(again.json().device.name, '나의 Mac'); + assert.equal(again.json().device.id, a.deviceId); + assert.equal((await f.call(null, '/device/me')).statusCode, 401); + assert.equal( + (await f.call({ ...a, cookie: `hop_guest=${a.deviceId}` }, '/device/me')).statusCode, + 401, + ); + assert.match(String(again.headers['set-cookie']), /HttpOnly/); + assert.match(String(again.headers['set-cookie']), /SameSite=Lax/); + } finally { + await f.close(); + } +}); +test('Origin, CSRF, HTML 이름 검증', async () => { + const f = await fixture(); + try { + const a = await f.create(); + assert.equal((await f.call(a, '/device/me', 'PATCH', { name: ''); + const received = (await f.call(b, '/device/items')).json().items[0]; + assert.equal(received.url, urlFor('안녕\n')); + assert.equal(received.read, false); + assert.equal( + (await f.call(c, `/access/${received.accessId}/read`, 'PATCH', { read: true })).statusCode, + 404, + ); + await f.call(b, `/access/${received.accessId}/read`, 'PATCH', { read: true }); + assert.equal((await f.call(a, '/device/items')).json().items[0].recipientRead, true); + await f.call(b, `/access/${received.accessId}/read`, 'PATCH', { read: false }); + assert.equal((await f.call(a, '/device/items')).json().items[0].recipientRead, false); + } finally { + await f.close(); + } +}); +test('다중 URL을 개별 항목으로 저장하고 내부 URL 전달을 허용', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + const urls = ['https://example.com/', 'http://192.168.1.10/']; + const response = await f.request(a, b, urls.join('\n'), 'url', 2); + const id = response.json().requestId; + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + const result = await f.call(a, `/transfers/${id}/complete`, 'POST', { urls }, randomUUID()); + assert.equal(result.statusCode, 200); + assert.equal((await f.call(b, '/device/items')).json().items.length, 2); + } finally { + await f.close(); + } +}); +test('URL 스크립트 프로토콜 및 승인 메타데이터 변경 차단', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + const r = await f.request(a, b, 'https://example.com/', 'url', 1); + const id = r.json().requestId; + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + assert.equal( + ( + await f.call( + a, + `/transfers/${id}/complete`, + 'POST', + { urls: ['javascript:alert(1)'] }, + randomUUID(), + ) + ).statusCode, + 400, + ); + assert.equal( + ( + await f.call( + a, + `/transfers/${id}/complete`, + 'POST', + { urls: ['https://example.com/longer'] }, + randomUUID(), + ) + ).statusCode, + 409, + ); + } finally { + await f.close(); + } +}); +test('동일 키 요청과 확정은 한 번만 처리하고 다른 내용은 충돌', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + const key = randomUUID(), + text = 'repeat'; + const one = await f.request(a, b, text, 'url', 1, key), + two = await f.request(a, b, text, 'url', 1, key); + assert.equal(one.json().requestId, two.json().requestId); + assert.equal((await f.request(a, b, 'different', 'url', 1, key)).statusCode, 409); + const id = one.json().requestId, + acceptKey = randomUUID(), + completeKey = randomUUID(); + assert.equal( + (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, acceptKey)).statusCode, + 200, + ); + assert.equal( + (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, acceptKey)).statusCode, + 200, + ); + await f.call(a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor(text)] }, completeKey); + await f.call(a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor(text)] }, completeKey); + assert.equal((await f.call(b, '/device/items')).json().items.length, 1); + } finally { + await f.close(); + } +}); +test('수신자만 수락하고 송신자만 확정 가능', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(), + c = await f.create(); + await f.space(a, b); + const id = (await f.request(a, b)).json().requestId; + assert.equal( + (await f.call(a, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, + 403, + ); + assert.equal( + (await f.call(c, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, + 404, + ); + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + assert.equal( + ( + await f.call( + b, + `/transfers/${id}/complete`, + 'POST', + { urls: [urlFor('안녕하세요')] }, + randomUUID(), + ) + ).statusCode, + 403, + ); + } finally { + await f.close(); + } +}); +test('거절, 만료, 취소 후 확정 금지와 거절 재요청 제한', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + const id = (await f.request(a, b)).json().requestId; + await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {}); + assert.equal( + ( + await f.call( + a, + `/transfers/${id}/complete`, + 'POST', + { urls: [urlFor('안녕하세요')] }, + randomUUID(), + ) + ).statusCode, + 409, + ); + assert.equal((await f.request(a, b)).statusCode, 429); + const c = await f.create(); + await f.space(a, c); + const second = (await f.request(a, c)).json().requestId; + f.service.db + .update(schema.requests) + .set({ expiresAt: Date.now() - 1 }) + .where(eq(schema.requests.id, second)) + .run(); + assert.equal( + (await f.call(c, `/transfer-requests/${second}/accept`, 'POST', {}, randomUUID())).statusCode, + 409, + ); + f.service.cleanup(); + assert.equal( + f.service.db.select().from(schema.requests).where(eq(schema.requests.id, second)).get()! + .status, + 'expired', + ); + } finally { + await f.close(); + } +}); +test('수락 직전 오프라인 검사 및 여러 탭 합산', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + const first = b.socket!; + const second = await f.connect(b); + first.disconnect(); + await new Promise((r) => setTimeout(r, 35)); + assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 1); + const id = (await f.request(a, b)).json().requestId; + second.disconnect(); + await new Promise((r) => setTimeout(r, 35)); + assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0); + assert.equal( + (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, + 409, + ); + } finally { + await f.close(); + } +}); +test('방 종료 시 진행 중 수락 전송도 취소되며 재연결로 확정할 수 없음', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + const invite = await f.space(a, b); + const id = (await f.request(a, b)).json().requestId; + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + await f.call(a, `/spaces/${invite.space.id}`, 'DELETE'); + b.socket!.disconnect(); + await new Promise((r) => setTimeout(r, 35)); + assert.equal( + ( + await f.call( + a, + `/transfers/${id}/complete`, + 'POST', + { urls: [urlFor('안녕하세요')] }, + randomUUID(), + ) + ).statusCode, + 409, + ); + await f.connect(b); + assert.equal((await f.call(b, '/device/items')).json().items.length, 0); + } finally { + await f.close(); + } +}); +test('익명 차단은 새로운 요청과 수락한 요청의 확정을 금지', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + const id = (await f.request(a, b)).json().requestId; + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + assert.equal((await f.call(b, `/transfer-requests/${id}/block`, 'POST', {})).statusCode, 200); + assert.equal( + ( + await f.call( + a, + `/transfers/${id}/complete`, + 'POST', + { urls: [urlFor('안녕하세요')] }, + randomUUID(), + ) + ).statusCode, + 409, + ); + assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0); + } finally { + await f.close(); + } +}); +test('익명 초기화는 이전 접근권만 폐기하고 상대 자료는 유지', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + await f.complete(a, b); + const response = await f.call(b, '/device/reset', 'POST', {}); + assert.equal(response.statusCode, 200); + assert.equal((await f.call(b, '/device/items')).statusCode, 401); + const next = response.json(); + const fresh = { + ...b, + cookie: String(response.headers['set-cookie']).split(';')[0]!, + csrf: next.csrfToken, + deviceId: next.device.id, + }; + assert.equal((await f.call(fresh, '/device/items')).json().items.length, 0); + assert.equal((await f.call(a, '/device/items')).json().items.length, 1); + } finally { + await f.close(); + } +}); +test('만료 자료는 조회 불가하고 본문 정리', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + await f.complete(a, b); + f.service.db + .update(schema.items) + .set({ expiresAt: Date.now() - 1 }) + .run(); + assert.equal((await f.call(b, '/device/items')).json().items.length, 0); + f.service.cleanup(); + assert.equal(f.service.db.select().from(schema.items).all().length, 0); + } finally { + await f.close(); + } +}); +test('코드 대입 횟수 제한과 공간 만료 적용', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + const invite = (await f.call(a, '/spaces', 'POST', {})).json(); + f.service.db + .update(schema.spaces) + .set({ expiresAt: Date.now() - 1 }) + .run(); + assert.equal((await f.call(b, '/spaces/join', 'POST', { code: invite.code })).statusCode, 404); + for (let i = 0; i < 10; i++) await f.call(b, '/spaces/join', 'POST', { code: 'ABCDEFGH' }); + assert.equal((await f.call(b, '/spaces/join', 'POST', { code: 'ABCDEFGH' })).statusCode, 429); + } finally { + await f.close(); + } +}); +test('임의 X-Forwarded-For는 신뢰하지 않고 명시된 프록시 체인만 해석', () => { + assert.equal(clientIp('8.8.8.8', { 'x-forwarded-for': '1.1.1.1' }, []), '8.8.8.8'); + assert.equal(clientIp('127.0.0.1', { 'x-forwarded-for': '1.1.1.1' }, ['127.0.0.1']), '1.1.1.1'); + assert.equal(clientIp('127.0.0.1', {}, ['127.0.0.1']), null); + assert.equal(clientIp('127.0.0.1', { 'x-forwarded-for': 'bogus' }, ['127.0.0.1']), null); + assert.equal( + clientIp('127.0.0.1', { 'x-forwarded-for': '8.8.8.8, 1.1.1.1' }, ['127.0.0.1']), + '1.1.1.1', + ); +}); + +test('신규 일반 텍스트 요청과 본문 확정을 거절하고 URL·파일만 지원', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + assert.equal( + ( + await f.call( + a, + '/transfer-requests', + 'POST', + { targetDeviceId: b.deviceId, kind: 'text', byteSize: 5, count: 1 }, + randomUUID(), + ) + ).statusCode, + 400, + ); + const id = (await f.request(a, b, 'https://example.com/')).json().requestId; + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + assert.equal( + (await f.call(a, `/transfers/${id}/complete`, 'POST', { text: 'hello' }, randomUUID())) + .statusCode, + 400, + ); + assert.equal((await f.call(b, '/device/snapshot')).json().items.length, 0); + assert.equal( + ( + await f.call( + a, + `/transfers/${id}/complete`, + 'POST', + { urls: ['https://example.com/'] }, + randomUUID(), + ) + ).statusCode, + 200, + ); + const snapshot = (await f.call(b, '/device/snapshot')).json(); + assert.equal(snapshot.items[0].kind, 'url'); + assert.equal(snapshot.items[0].url, 'https://example.com/'); + assert.equal('text' in snapshot.items[0], false); + } finally { + await f.close(); + } +}); diff --git a/apps/server/test/phase2.test.ts b/apps/server/test/phase2.test.ts new file mode 100644 index 0000000..506561d --- /dev/null +++ b/apps/server/test/phase2.test.ts @@ -0,0 +1,411 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { readFile, mkdtemp, rm, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { eq } from 'drizzle-orm'; +import { readMigrationFiles } from 'drizzle-orm/migrator'; +import Database from 'better-sqlite3'; +import sharp from 'sharp'; +import { fixture } from './helpers.js'; +import * as s from '../src/db/schema.js'; +import { openDatabase } from '../src/db/index.js'; +async function fileRequest( + f: Awaited>, + a: any, + b: any, + files: { name: string; size: number; type: string }[], +) { + const r = await f.call( + a, + '/transfer-requests', + 'POST', + { + targetDeviceId: b.deviceId, + kind: 'file', + byteSize: files.reduce((sum, v) => sum + v.size, 0), + count: files.length, + files, + }, + randomUUID(), + ); + assert.equal(r.statusCode, 200, r.body); + return r.json().requestId as string; +} +async function upload( + f: Awaited>, + a: any, + id: string, + name: string, + bytes: Buffer, + index = 0, + key = randomUUID(), +) { + const boundary = `hop${randomUUID()}`; + const body = Buffer.concat([ + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: application/octet-stream\r\n\r\n`, + ), + bytes, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]); + return f.call(a, `/uploads?requestId=${id}&fileIndex=${index}`, 'POST', body, key, { + 'content-type': `multipart/form-data; boundary=${boundary}`, + }); +} +test('파일은 수락 전 업로드되지 않으며 실제 스트리밍 저장과 인증 다운로드', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(), + c = await f.create(); + await f.space(a, b); + const bytes = Buffer.from('실제 파일 내용'), + id = await fileRequest(f, a, b, [ + { name: '파일.txt', size: bytes.length, type: 'text/plain' }, + ]); + assert.equal((await upload(f, a, id, '파일.txt', bytes)).statusCode, 409); + assert.equal((await readdir(f.service.storage.originals)).length, 0); + assert.equal( + (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, + 200, + ); + const uploaded = await upload(f, a, id, '파일.txt', bytes); + assert.equal(uploaded.statusCode, 200, uploaded.body); + const item = (await f.call(b, '/device/items')).json().items[0]; + assert.equal(item.file.name, '파일.txt'); + assert.equal((await f.call(c, `/access/${item.accessId}/download`)).statusCode, 404); + const download = await f.call(b, `/access/${item.accessId}/download`); + assert.equal(download.statusCode, 200); + assert.deepEqual(download.rawPayload, bytes); + assert.match(String(download.headers['content-disposition']), /attachment/); + assert.equal(download.headers['x-content-type-options'], 'nosniff'); + assert.equal(f.service.db.select().from(s.reservations).all().length, 0); + } finally { + await f.close(); + } +}); +test('선언 크기와 다른 파일, 요청 밖 파일, 이름 변경을 거절하고 임시 파일 회수', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + const id = await fileRequest(f, a, b, [{ name: 'a.txt', size: 4, type: 'text/plain' }]); + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + assert.equal((await upload(f, a, id, 'a.txt', Buffer.from('excess'))).statusCode, 413); + assert.equal((await readdir(f.service.storage.temp)).length, 0); + assert.equal((await upload(f, a, id, 'changed.txt', Buffer.from('test'))).statusCode, 409); + assert.equal((await upload(f, a, id, 'a.txt', Buffer.from('test'), 1)).statusCode, 400); + assert.equal((await upload(f, a, id, 'a.txt', Buffer.from('test'))).statusCode, 200); + } finally { + await f.close(); + } +}); +test('부분 성공은 유지하고 실패 파일만 재시도', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + const id = await fileRequest(f, a, b, [ + { name: 'one.txt', size: 3, type: 'text/plain' }, + { name: 'two.txt', size: 3, type: 'text/plain' }, + ]); + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + assert.equal((await upload(f, a, id, 'one.txt', Buffer.from('one'))).statusCode, 200); + assert.equal((await upload(f, a, id, 'two.txt', Buffer.from('badsize'), 1)).statusCode, 413); + assert.equal((await f.call(b, '/device/items')).json().items.length, 1); + assert.equal((await upload(f, a, id, 'two.txt', Buffer.from('two'), 1)).statusCode, 200); + assert.equal((await f.call(b, '/device/items')).json().items.length, 2); + assert.equal( + f.service.storage.fileRows(id).every((v) => v.status === 'stored'), + true, + ); + } finally { + await f.close(); + } +}); +test('예약을 원자적으로 적용해 전체 익명 상한을 우회하지 못한다', async () => { + const f = await fixture(); + try { + f.cfg.totalGuestQuota = 4; + const a = await f.create(), + b = await f.create(), + c = await f.create(); + await f.space(a, b); + await f.space(a, c); + const one = await fileRequest(f, a, b, [ + { name: 'one', size: 4, type: 'application/octet-stream' }, + ]), + two = await fileRequest(f, c, b, [ + { name: 'two', size: 4, type: 'application/octet-stream' }, + ]); + const results = await Promise.all([ + f.call(b, `/transfer-requests/${one}/accept`, 'POST', {}, randomUUID()), + f.call(b, `/transfer-requests/${two}/accept`, 'POST', {}, randomUUID()), + ]); + assert.deepEqual(results.map((r) => r.statusCode).sort(), [200, 507]); + assert.equal( + f.service.db + .select() + .from(s.reservations) + .all() + .reduce((sum, r) => sum + r.bytes, 0), + 4, + ); + } finally { + await f.close(); + } +}); +test('개별 익명 용량, 파일당 제한과 취소 후 예약 회수', async () => { + const f = await fixture(); + try { + f.cfg.guestQuota = 3; + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + const id = await fileRequest(f, a, b, [ + { name: 'large', size: 4, type: 'application/octet-stream' }, + ]); + assert.equal( + (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, + 413, + ); + f.cfg.guestQuota = 4; + assert.equal( + (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, + 200, + ); + await f.call(a, `/transfer-requests/${id}/cancel`, 'POST', {}); + assert.equal(f.service.db.select().from(s.reservations).all().length, 0); + assert.equal((await upload(f, a, id, 'large', Buffer.from('test'))).statusCode, 409); + } finally { + await f.close(); + } +}); +test('인증 래스터 미리보기는 읽음이 아니며 SVG와 실행 콘텐츠는 거절', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + const png = await sharp({ + create: { width: 16, height: 16, channels: 3, background: '#365e40' }, + }) + .png() + .toBuffer(); + const id = await fileRequest(f, a, b, [ + { name: 'test.png', size: png.length, type: 'image/png' }, + ]); + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + await upload(f, a, id, 'test.png', png); + const item = (await f.call(b, '/device/items')).json().items[0]; + const preview = await f.call(b, `/access/${item.accessId}/preview`); + assert.equal(preview.statusCode, 200); + assert.equal(preview.headers['content-type'], 'image/webp'); + assert.equal((await f.call(b, '/device/items')).json().items[0].read, false); + const c = await f.create(); + await f.space(a, c); + const svg = Buffer.from( + '', + ); + const svgId = await fileRequest(f, c, b, [ + { name: 'evil.svg', size: svg.length, type: 'image/svg+xml' }, + ]); + await f.call(b, `/transfer-requests/${svgId}/accept`, 'POST', {}, randomUUID()); + await upload(f, c, svgId, 'evil.svg', svg); + const svgItem = (await f.call(b, '/device/items')) + .json() + .items.find((v: any) => v.file.name === 'evil.svg'); + assert.equal((await f.call(b, `/access/${svgItem.accessId}/preview`)).statusCode, 415); + } finally { + await f.close(); + } +}); +test('내 목록 삭제는 상대 접근권을 유지하고 서버 복원 ID로 실행 취소', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(), + c = await f.create(); + await f.space(a, b); + await f.complete(a, b); + const item = (await f.call(b, '/device/items')).json().items[0]; + const deletion = ( + await f.call(b, '/access/delete-batch', 'POST', { ids: [item.accessId] }) + ).json(); + assert.equal((await f.call(b, '/device/items')).json().items.length, 0); + assert.equal((await f.call(a, '/device/items')).json().items.length, 1); + assert.equal( + (await f.call(c, `/deletion-operations/${deletion.operationId}/undo`, 'POST', {})).statusCode, + 404, + ); + assert.equal( + (await f.call(b, `/deletion-operations/${deletion.operationId}/undo`, 'POST', {})).statusCode, + 200, + ); + assert.equal((await f.call(b, '/device/items')).json().items.length, 1); + } finally { + await f.close(); + } +}); +test('실행 취소 기한과 자연 만료를 검사하며 마지막 권한 뒤 파일 정리', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + const id = await fileRequest(f, a, b, [{ name: 'delete.txt', size: 4, type: 'text/plain' }]); + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + await upload(f, a, id, 'delete.txt', Buffer.from('test')); + const sender = (await f.call(a, '/device/items')).json().items[0], + receiver = (await f.call(b, '/device/items')).json().items[0]; + await f.call(a, '/access/delete-batch', 'POST', { ids: [sender.accessId] }); + const d = ( + await f.call(b, '/access/delete-batch', 'POST', { ids: [receiver.accessId] }) + ).json(); + await f.service.storage.cleanup(); + assert.equal((await readdir(f.service.storage.originals)).length, 1); + f.service.db + .update(s.deletions) + .set({ undoUntil: Date.now() - 1 }) + .where(eq(s.deletions.id, d.operationId)) + .run(); + assert.equal( + (await f.call(b, `/deletion-operations/${d.operationId}/undo`, 'POST', {})).statusCode, + 409, + ); + f.service.db + .update(s.access) + .set({ deletedAt: Date.now() - 6000 }) + .run(); + f.service.cleanup(); + await f.service.storage.cleanup(); + assert.equal((await readdir(f.service.storage.originals)).length, 0); + } finally { + await f.close(); + } +}); +test('Phase 1 자료가 있는 DB를 Phase 2로 마이그레이션해도 본문과 접근권 유지', async () => { + const folder = await mkdtemp(join(tmpdir(), 'hop-migrate-')); + try { + const path = join(folder, 'old.sqlite'), + old = new Database(path); + const migrations = readMigrationFiles({ + migrationsFolder: join(import.meta.dirname, '../drizzle'), + }); + old.exec( + 'CREATE TABLE __drizzle_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT,hash TEXT NOT NULL,created_at INTEGER)', + ); + for (const migration of migrations.slice(0, 2)) { + for (const sql of migration.sql) old.exec(sql); + old + .prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)') + .run(migration.hash, migration.folderMillis); + } + const now = Date.now(); + for (const id of ['a', 'b']) + old + .prepare('INSERT INTO guest_sessions(id,tokenHash,expiresAt,createdAt) VALUES (?,?,?,?)') + .run(id, id, now + 86400000, now); + old + .prepare( + 'INSERT INTO transfer_requests(id,mode,senderGuestId,recipientGuestId,kind,byteSize,count,status,createdAt,expiresAt) VALUES (?,?,?,?,?,?,?,?,?,?)', + ) + .run('request', 'local', 'a', 'b', 'text', 4, 1, 'accepted', now, now + 60000); + old + .prepare('INSERT INTO transfer_batches(id,requestId,status) VALUES (?,?,?)') + .run('batch', 'request', 'stored'); + old + .prepare('INSERT INTO items(id,batchId,kind,text,createdAt,expiresAt) VALUES (?,?,?,?,?,?)') + .run('item', 'batch', 'text', 'keep', now, now + 86400000); + old + .prepare('INSERT INTO item_access(id,itemId,guestId,role,read) VALUES (?,?,?,?,?)') + .run('access', 'item', 'b', 'recipient', 0); + old.close(); + const { sqlite } = openDatabase(path); + assert.equal( + (sqlite.prepare('SELECT text FROM items WHERE id=?').get('item') as any).text, + 'keep', + ); + assert.equal((sqlite.prepare('SELECT count(*) AS n FROM item_access').get() as any).n, 1); + assert.equal(sqlite.pragma('foreign_key_check').length, 0); + assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1); + sqlite.close(); + } finally { + await rm(folder, { recursive: true, force: true }); + } +}); + +test('업로드 확정 재시도는 같은 키와 바이트에만 동일 결과를 반환', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + const id = await fileRequest(f, a, b, [{ name: 'retry.txt', size: 4, type: 'text/plain' }]), + key = randomUUID(); + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + const first = await upload(f, a, id, 'retry.txt', Buffer.from('same'), 0, key), + second = await upload(f, a, id, 'retry.txt', Buffer.from('same'), 0, key); + assert.equal(first.statusCode, 200); + assert.deepEqual(first.json(), second.json()); + assert.equal( + (await upload(f, a, id, 'retry.txt', Buffer.from('diff'), 0, key)).statusCode, + 409, + ); + assert.equal((await f.call(b, '/device/items')).json().items.length, 1); + } finally { + await f.close(); + } +}); + +test('실제 HTTP 업로드 중단 시 임시 파일과 해당 파일 예약을 회수', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + const id = await fileRequest(f, a, b, [ + { name: 'abort.bin', size: 1048576, type: 'application/octet-stream' }, + ]); + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + const { Readable } = await import('node:stream'); + const controller = new AbortController(), + boundary = 'hop-abort'; + async function* bytes() { + yield Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="abort.bin"\r\nContent-Type: application/octet-stream\r\n\r\n`, + ); + yield Buffer.alloc(65536); + while (!controller.signal.aborted) await new Promise((r) => setTimeout(r, 10)); + } + const pending = fetch(f.url + `/api/uploads?requestId=${id}&fileIndex=0`, { + method: 'POST', + headers: { + Origin: 'http://hop.test', + Cookie: a.cookie, + 'X-CSRF-Token': a.csrf, + 'Idempotency-Key': randomUUID(), + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + }, + body: Readable.from(bytes()), + duplex: 'half', + signal: controller.signal, + } as any).catch(() => null); + for (let i = 0; i < 100 && f.service.storage.fileRows(id)[0]?.status !== 'uploading'; i++) + await new Promise((r) => setTimeout(r, 10)); + assert.equal(f.service.storage.fileRows(id)[0]!.status, 'uploading'); + controller.abort(); + await pending; + for (let i = 0; i < 100 && f.service.storage.fileRows(id)[0]?.status !== 'failed'; i++) + await new Promise((r) => setTimeout(r, 10)); + assert.equal(f.service.storage.fileRows(id)[0]!.status, 'failed'); + assert.equal((await readdir(f.service.storage.temp)).length, 0); + assert.equal(f.service.db.select().from(s.reservations).all().length, 0); + } finally { + await f.close(); + } +}); diff --git a/apps/server/test/phase3.test.ts b/apps/server/test/phase3.test.ts new file mode 100644 index 0000000..d4424c2 --- /dev/null +++ b/apps/server/test/phase3.test.ts @@ -0,0 +1,470 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { fixture, urlFor } from './helpers.js'; +import type { Mail } from '../src/auth.js'; +import { normalizeEmail } from '../src/auth.js'; +import * as s from '../src/db/schema.js'; +const password = 'correct horse battery staple'; +test('개발은 1자 비밀번호를 허용하고 운영은 12자 규칙을 유지', async () => { + const f = await fixture(); + try { + assert.equal((await f.call(null, '/auth/me')).json().passwordMinLength, 1); + assert.match(await f.service.auth.password('1'), /^\$argon2id\$/); + await assert.rejects(() => f.service.auth.password('')); + f.cfg.production = true; + assert.equal((await f.call(null, '/auth/me')).json().passwordMinLength, 12); + await assert.rejects(() => f.service.auth.password('1234'), /12~1024/); + assert.match(await f.service.auth.password('123412341234'), /^\$argon2id\$/); + } finally { + await f.close(); + } +}); +async function accountFixture() { + const mails: Mail[] = []; + const f = await fixture(false, async (mail) => { + mails.push(mail); + }); + f.cfg.registrationMode = 'open'; + return { ...f, mails }; +} +async function register( + f: Awaited>, + email: string, + verify = true, +) { + const c = await f.create(); + const result = await f.call(c, '/auth/register', 'POST', { + email, + password, + passwordConfirmation: password, + displayName: email.split('@')[0], + }); + assert.equal(result.statusCode, 200, result.body); + const mail = f.mails.find((m) => m.to === email)!; + const token = mail.text.match(/#verify=([A-Za-z0-9_-]+)/)![1]!; + if (verify) + assert.equal((await f.call(c, '/auth/verify-email', 'POST', { token })).statusCode, 200); + const login = await f.call(c, '/auth/login', 'POST', { email, password }); + assert.equal(login.statusCode, 200, login.body); + c.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0]; + c.socket?.disconnect(); + await f.connect(c); + return { ...c, userId: login.json().account.id, token }; +} +async function request( + f: Awaited>, + a: any, + email: string, + text = 'remote text', +) { + return legacyRequest(f, a, { + email, + kind: 'url', + byteSize: Buffer.byteLength(urlFor(text)), + count: 1, + }); +} +// Reproduce requests already stored by the previous email system; this is not a public creation API. +async function legacyRequest(f: any, a: any, input: any) { + try { + const result = f.service.accounts.create(a.userId, input); + f.service.notifyTransfer(result.requestId); + return { statusCode: 200, body: JSON.stringify(result), json: () => result }; + } catch (error: any) { + return { + statusCode: error.status, + body: error.message, + json: () => ({ message: error.message }), + }; + } +} +test('가입은 메일 게이트웨이로 인증 토큰을 보내고 해시만 저장, 인증과 로그인 회전', async () => { + const f = await accountFixture(); + try { + const a = await register(f, 'alice@example.com', false); + assert.equal(f.mails.length, 1); + const user = f.service.db.select().from(s.users).get()!; + assert.match(user.passwordHash!, /^\$argon2id\$/); + assert.equal(f.service.db.select().from(s.accountTokens).get()!.tokenHash === a.token, false); + assert.equal((await request(f, a, 'alice@example.com')).statusCode, 403); + assert.equal( + (await f.call(a, '/auth/verify-email', 'POST', { token: a.token })).statusCode, + 200, + ); + assert.equal( + (await f.call(a, '/auth/verify-email', 'POST', { token: a.token })).statusCode, + 400, + ); + const oldCookie = a.cookie; + const again = await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password }); + assert.equal(again.statusCode, 200); + assert.equal((await f.call({ ...a, cookie: oldCookie }, '/auth/me')).json().account, null); + assert.equal((await f.call(a, '/device/me')).json().device.id, a.deviceId); + } finally { + await f.close(); + } +}); +test('메일 미설정 상태에서 공개 가입을 열 수 없고 폐쇄 가입은 거절', async () => { + const f = await fixture(); + try { + const a = await f.create(); + const input = { + email: 'alice@example.com', + password, + passwordConfirmation: password, + displayName: 'Alice', + }; + assert.equal((await f.call(a, '/auth/register', 'POST', input)).statusCode, 403); + f.cfg.registrationMode = 'open'; + assert.equal((await f.call(a, '/auth/register', 'POST', input)).statusCode, 503); + } finally { + await f.close(); + } +}); +test('기존 이메일 자료: 낯선 계정은 메타데이터 요청만 만들고 수락 후 실제 전달 및 허용 관계', async () => { + const f = await accountFixture(); + try { + const a = await register(f, 'alice@example.com'), + b = await register(f, 'bob@example.com'), + c = await register(f, 'charlie@example.com'); + const response = await request(f, a, 'bob@example.com', '비공개 원격 본문'); + assert.equal(response.statusCode, 200, response.body); + assert.equal(response.json().requiresAcceptance, true); + const id = response.json().requestId; + assert.equal((await f.call(b, '/inbox')).json().items.length, 0); + assert.equal( + JSON.stringify((await f.call(b, '/transfer-requests?mode=email')).json()).includes( + '비공개 원격 본문', + ), + false, + ); + assert.equal( + ( + await f.call( + a, + `/transfers/${id}/complete`, + 'POST', + { urls: [urlFor('비공개 원격 본문')] }, + randomUUID(), + ) + ).statusCode, + 409, + ); + assert.equal( + (await f.call(c, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, + 404, + ); + assert.equal( + ( + await f.call( + b, + `/transfer-requests/${id}/accept`, + 'POST', + { allowSender: true }, + randomUUID(), + ) + ).statusCode, + 200, + ); + assert.equal( + ( + await f.call( + a, + `/transfers/${id}/complete`, + 'POST', + { urls: [urlFor('비공개 원격 본문')] }, + randomUUID(), + ) + ).statusCode, + 200, + ); + const item = (await f.call(b, '/inbox')).json().items[0]; + assert.equal(item.url, urlFor('비공개 원격 본문')); + assert.equal((await f.call(c, `/access/${item.accessId}`)).statusCode, 404); + assert.equal((await f.call(b, '/device/items')).json().items.length, 0); + await f.call(b, `/access/${item.accessId}/read`, 'PATCH', { read: true }); + assert.equal((await f.call(a, '/sent')).json().items[0].recipientRead, true); + b.socket?.disconnect(); + const second = await request(f, a, 'bob@example.com'); + assert.equal(second.statusCode, 200); + assert.equal(second.json().requiresAcceptance, false); + assert.equal( + ( + await f.call( + a, + `/transfers/${second.json().requestId}/complete`, + 'POST', + { urls: [urlFor('remote text')] }, + randomUUID(), + ) + ).statusCode, + 200, + ); + assert.equal((await f.call(b, '/inbox')).json().items.length, 2); + } finally { + await f.close(); + } +}); +test('자신에게 저장은 요청 수락 없이 내 보관함이며 계정 간 상태 격리', async () => { + const f = await accountFixture(); + try { + const a = await register(f, 'alice@example.com'), + b = await register(f, 'bob@example.com'); + const r = await request(f, a, 'alice@example.com'); + assert.equal(r.statusCode, 200); + assert.equal(r.json().requiresAcceptance, false); + await f.call( + a, + `/transfers/${r.json().requestId}/complete`, + 'POST', + { urls: [urlFor('remote text')] }, + randomUUID(), + ); + const item = (await f.call(a, '/vault')).json().items[0]; + assert.equal(item.role, 'self'); + assert.equal((await f.call(a, '/inbox')).json().items.length, 0); + assert.equal( + (await f.call(b, `/access/${item.accessId}/read`, 'PATCH', { read: true })).statusCode, + 404, + ); + } finally { + await f.close(); + } +}); +test('기존 이메일 자료: 차단이 허용보다 우선하며 주소 오류는 일반화', async () => { + const f = await accountFixture(); + try { + const a = await register(f, 'alice@example.com'), + b = await register(f, 'bob@example.com'); + const id = (await request(f, a, 'bob@example.com')).json().requestId; + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', { allowSender: true }, randomUUID()); + await f.call(b, `/account/rules/${a.userId}`, 'PUT', { action: 'block' }); + const blocked = await request(f, a, 'bob@example.com'), + unknown = await request(f, a, 'nobody@example.com'); + assert.equal(blocked.statusCode, 400); + assert.equal(unknown.statusCode, 400); + assert.equal(blocked.json().message, unknown.json().message); + assert.equal( + ( + await f.call( + a, + `/transfers/${id}/complete`, + 'POST', + { urls: [urlFor('remote text')] }, + randomUUID(), + ) + ).statusCode, + 409, + ); + } finally { + await f.close(); + } +}); +test('비밀번호 재설정 토큰은 일회용이며 모든 기존 세션을 무효화', async () => { + const f = await accountFixture(); + try { + const a = await register(f, 'alice@example.com'); + const another = await f.call(a, '/auth/login', 'POST', { + email: 'alice@example.com', + password, + }); + a.cookie = + a.cookie.split('; hop_account=')[0] + + '; ' + + String(another.headers['set-cookie']).split(';')[0]; + await f.call(a, '/auth/forgot-password', 'POST', { email: 'alice@example.com' }); + const mail = f.mails.at(-1)!, + token = mail.text.match(/#reset-password=([A-Za-z0-9_-]+)/)![1]!, + next = 'a different long password'; + const response = await f.call(a, '/auth/reset-password', 'POST', { + token, + password: next, + passwordConfirmation: next, + }); + assert.equal(response.statusCode, 200); + assert.equal((await f.call(a, '/auth/me')).json().account, null); + assert.equal( + ( + await f.call(a, '/auth/reset-password', 'POST', { + token, + password: next, + passwordConfirmation: next, + }) + ).statusCode, + 400, + ); + assert.equal( + (await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password })).statusCode, + 401, + ); + assert.equal( + (await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password: next })) + .statusCode, + 200, + ); + } finally { + await f.close(); + } +}); +test('로그인 중 받은 자료는 받은 항목에 자동 기록하며 익명 가져오기는 제거', async () => { + const f = await accountFixture(); + try { + const a = await register(f, 'alice@example.com'), + b = await f.create(); + await f.space(a, b); + await f.complete(b, a, '자동 기록'); + const item = (await f.call(a, '/device/items')).json().items[0]; + assert.equal( + (await f.call(a, `/access/${item.accessId}/save-to-account`, 'POST', {})).statusCode, + 410, + ); + const saved = (await f.call(a, '/inbox')).json().items[0]; + assert.equal(saved.url, item.url); + assert.equal(saved.expiresAt, item.expiresAt); + assert.equal((await f.call(a, '/vault')).json().items.length, 0); + await f.call(a, '/auth/logout', 'POST', {}); + assert.equal((await f.call(a, '/inbox')).statusCode, 401); + } finally { + await f.close(); + } +}); +test('즐겨찾기 영구 보관 제거와 만료된 자료의 실제 정리', async () => { + const f = await accountFixture(); + try { + const a = await register(f, 'alice@example.com'), + b = await register(f, 'bob@example.com'); + const id = (await request(f, a, 'bob@example.com')).json().requestId; + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + await f.call( + a, + `/transfers/${id}/complete`, + 'POST', + { urls: [urlFor('remote text')] }, + randomUUID(), + ); + const item = (await f.call(b, '/inbox')).json().items[0]; + assert.equal( + (await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: true })) + .statusCode, + 410, + ); + f.service.db + .update(s.items) + .set({ expiresAt: Date.now() - 1 }) + .run(); + f.service.cleanup(); + assert.equal((await f.call(b, '/favorites')).json().items.length, 0); + assert.equal((await f.call(a, '/sent')).json().items.length, 0); + await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: false }); + assert.equal((await f.call(b, '/inbox')).json().items.length, 0); + } finally { + await f.close(); + } +}); +test('이메일 정규화는 도메인만 소문자화하고 제공자 점·별칭을 보존', () => { + assert.equal(normalizeEmail(' Alice.Smith+tag@EXAMPLE.COM '), 'Alice.Smith+tag@example.com'); + assert.notEqual(normalizeEmail('Alice@example.com'), normalizeEmail('alice@example.com')); +}); + +async function fileUpload( + f: Awaited>, + a: any, + id: string, + bytes: Buffer, +) { + const boundary = 'hop-account-file'; + const payload = Buffer.concat([ + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="account.txt"\r\nContent-Type: text/plain\r\n\r\n`, + ), + bytes, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]); + return f.call(a, `/uploads?requestId=${id}&fileIndex=0`, 'POST', payload, randomUUID(), { + 'content-type': `multipart/form-data; boundary=${boundary}`, + }); +} +test('기존 이메일 자료: 계정 파일은 수락 후 저장되고 익명 상한과 계정 용량을 분리', async () => { + const f = await accountFixture(); + try { + const a = await register(f, 'alice@example.com'), + b = await register(f, 'bob@example.com'); + f.cfg.totalGuestQuota = 1; + const bytes = Buffer.from('account file'); + const response = await legacyRequest(f, a, { + email: 'bob@example.com', + kind: 'file', + byteSize: bytes.length, + count: 1, + files: [{ name: 'account.txt', size: bytes.length, type: 'text/plain' }], + }); + assert.equal(response.statusCode, 200); + const id = response.json().requestId; + assert.equal((await fileUpload(f, a, id, bytes)).statusCode, 409); + f.cfg.userQuota = bytes.length - 1; + assert.equal( + (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, + 413, + ); + f.cfg.userQuota = bytes.length; + assert.equal( + (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, + 200, + ); + const upload = await fileUpload(f, a, id, bytes); + assert.equal(upload.statusCode, 200, upload.body); + const item = (await f.call(b, '/inbox')).json().items[0], + download = await f.call(b, `/access/${item.accessId}/download`); + assert.deepEqual(download.rawPayload, bytes); + assert.equal((await f.call(a, '/account/storage')).json().usedBytes, bytes.length); + assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0); + assert.equal( + (await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: true })) + .statusCode, + 410, + ); + assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0); + } finally { + await f.close(); + } +}); +test('로그인 수신 파일은 계정에 기록하되 보내는 익명 파일 소유권을 옮기지 않음', async () => { + const f = await accountFixture(); + try { + const a = await register(f, 'alice@example.com'), + b = await register(f, 'bob@example.com'); + await f.space(a, b); + const bytes = Buffer.from('move my file'); + const r = await f.call( + a, + '/transfer-requests', + 'POST', + { + targetDeviceId: b.deviceId, + kind: 'file', + byteSize: bytes.length, + count: 1, + files: [{ name: 'account.txt', size: bytes.length, type: 'text/plain' }], + }, + randomUUID(), + ); + const id = r.json().requestId; + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + assert.equal((await fileUpload(f, a, id, bytes)).statusCode, 200); + const item = (await f.call(a, '/device/items')).json().items[0]; + assert.equal( + (await f.call(a, `/access/${item.accessId}/save-to-account`, 'POST', {})).statusCode, + 410, + ); + assert.equal( + f.service.storage.fileRows(id)[0]!.ownerGuestId, + f.service.deviceGuest(a.deviceId)!.guestId, + ); + const received = (await f.call(b, '/inbox')).json().items[0]; + assert.deepEqual((await f.call(b, `/access/${received.accessId}/download`)).rawPayload, bytes); + } finally { + await f.close(); + } +}); diff --git a/apps/server/test/refresh-queue.test.ts b/apps/server/test/refresh-queue.test.ts new file mode 100644 index 0000000..f707a1e --- /dev/null +++ b/apps/server/test/refresh-queue.test.ts @@ -0,0 +1,40 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRefreshQueue } from '../../web/src/refreshQueue.js'; + +test('조회 중 들어온 수락 이벤트를 버리지 않고 최신 상태를 다시 읽는다', async () => { + let status = 'pending', + seen: string[] = []; + let release!: () => void, started!: () => void; + const firstStarted = new Promise((resolve) => { + started = resolve; + }); + const pause = new Promise((resolve) => { + release = resolve; + }); + const refresh = createRefreshQueue(async () => { + const snapshot = status; + if (!seen.length) { + started(); + await pause; + } + seen.push(snapshot); + }); + const initial = refresh(); + await firstStarted; + status = 'accepted'; + const update = refresh(); + refresh(); + release(); + await Promise.all([initial, update]); + assert.deepEqual(seen, ['pending', 'accepted']); +}); +test('실패한 조회 다음 요청은 정상 재시도', async () => { + let calls = 0; + const refresh = createRefreshQueue(async () => { + if (++calls === 1) throw Error('network'); + }); + await assert.rejects(refresh(), /network/); + await refresh(); + assert.equal(calls, 2); +}); diff --git a/apps/server/test/retention-migration.test.js b/apps/server/test/retention-migration.test.js new file mode 100644 index 0000000..941f457 --- /dev/null +++ b/apps/server/test/retention-migration.test.js @@ -0,0 +1,66 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import Database from 'better-sqlite3'; +import { readMigrationFiles } from 'drizzle-orm/migrator'; +import { openDatabase } from '../src/db/index.js'; + +test('기존 30일·즐겨찾기 자료는 ID·본문·권한을 유지하며 변경 시점부터 최대 7일로 제한', async () => { + const folder = await mkdtemp(join(tmpdir(), 'hop-retention-')); + try { + const path = join(folder, 'existing.sqlite'), + old = new Database(path); + const migrations = readMigrationFiles({ + migrationsFolder: join(import.meta.dirname, '../drizzle'), + }); + old.exec( + 'CREATE TABLE __drizzle_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL, created_at INTEGER)', + ); + for (const migration of migrations.slice(0, 8)) { + for (const statement of migration.sql) old.exec(statement); + old + .prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)') + .run(migration.hash, migration.folderMillis); + } + const now = Date.now(); + for (const id of ['a', 'b']) + old + .prepare('INSERT INTO guest_sessions(id,tokenHash,expiresAt,createdAt) VALUES (?,?,?,?)') + .run(id, id, now + 30 * 86400000, now); + old + .prepare( + 'INSERT INTO transfer_requests(id,senderGuestId,recipientGuestId,kind,byteSize,count,status,createdAt,expiresAt) VALUES (?,?,?,?,?,?,?,?,?)', + ) + .run('request', 'a', 'b', 'url', 1, 1, 'accepted', now, now + 60000); + old + .prepare('INSERT INTO transfer_batches(id,requestId,status) VALUES (?,?,?)') + .run('batch', 'request', 'stored'); + old + .prepare('INSERT INTO items(id,batchId,kind,url,createdAt,expiresAt) VALUES (?,?,?,?,?,?)') + .run('item', 'batch', 'url', 'https://example.com/existing', now, now + 30 * 86400000); + old + .prepare('INSERT INTO item_access(id,itemId,guestId,role,favorite) VALUES (?,?,?,?,?)') + .run('access', 'item', 'b', 'recipient', 1); + old.close(); + const migrated = openDatabase(path); + try { + const item = migrated.sqlite.prepare('SELECT * FROM items WHERE id=?').get('item'); + assert.equal(item.url, 'https://example.com/existing'); + assert( + item.expiresAt <= Date.now() + 7 * 86400000 && item.expiresAt >= now + 7 * 86400000 - 1500, + ); + const access = migrated.sqlite.prepare('SELECT * FROM item_access WHERE id=?').get('access'); + assert.equal(access.guestId, 'b'); + assert.equal(access.favorite, 0); + assert.equal(access.note, null); + assert.equal(migrated.sqlite.pragma('foreign_key_check').length, 0); + assert.equal(migrated.sqlite.pragma('integrity_check', { simple: true }), 'ok'); + } finally { + migrated.sqlite.close(); + } + } finally { + await rm(folder, { recursive: true, force: true }); + } +}); diff --git a/apps/server/test/rooms.test.js b/apps/server/test/rooms.test.js new file mode 100644 index 0000000..11bd809 --- /dev/null +++ b/apps/server/test/rooms.test.js @@ -0,0 +1,90 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { fixture } from './helpers.js'; +import * as s from '../src/db/schema.js'; + +test('주변 발견은 방 참여자를 제외하고 한 기기는 한 방만 참여, 방 종료 후 모두 주변으로 복귀', async () => { + const f = await fixture(true); + try { + const a = await f.create('8.8.8.8'), + b = await f.create('8.8.8.8'), + c = await f.create('8.8.8.8'); + assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 2); + const pending = (await f.request(a, b)).json().requestId; + const room = (await f.call(a, '/spaces', 'POST', {})).json(); + assert.equal( + f.service.db.select().from(s.requests).where(eq(s.requests.id, pending)).get().status, + 'cancelled', + ); + assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0); + assert.equal( + (await f.call(b, '/discovery/devices')) + .json() + .devices.some((device) => device.id === a.deviceId), + false, + ); + assert.equal((await f.request(b, a)).statusCode, 403); + assert.equal((await f.call(b, '/spaces/join', 'POST', { code: room.code })).statusCode, 200); + assert.equal((await f.call(b, '/discovery/devices')).json().devices.length, 1); + assert.equal((await f.call(b, '/spaces', 'POST', {})).statusCode, 409); + const other = (await f.call(c, '/spaces', 'POST', {})).json(); + assert.equal((await f.call(b, '/spaces/join', 'POST', { code: other.code })).statusCode, 409); + assert.throws( + () => + f.service.db + .insert(s.members) + .values({ + id: randomUUID(), + spaceId: other.space.id, + deviceId: b.deviceId, + status: 'approved', + joinedAt: Date.now(), + }) + .run(), + /UNIQUE/, + ); + assert.equal((await f.call(a, `/spaces/${room.space.id}`, 'DELETE')).statusCode, 200); + assert.equal((await f.call(b, '/spaces')).json().spaces.length, 0); + assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 1); + await f.call(c, `/spaces/${other.space.id}`, 'DELETE'); + assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 2); + assert.equal((await f.call(b, '/spaces', 'POST', {})).statusCode, 200); + } finally { + await f.close(); + } +}); + +test('승인 대기 참여자도 주변에서 숨고 방장이 승인한 뒤에만 같은 방 전송 가능', async () => { + const f = await fixture(true); + try { + const a = await f.create('8.8.8.8'), + b = await f.create('8.8.8.8'), + c = await f.create('8.8.8.8'); + const room = await f.space(a, b, true); + assert.equal((await f.call(c, '/discovery/devices')).json().devices.length, 0); + assert.equal((await f.request(a, b)).statusCode, 403); + await f.call(a, `/spaces/${room.space.id}/members/${b.deviceId}/approve`, 'POST', {}); + const request = await f.request(a, b); + assert.equal(request.statusCode, 200); + await f.call( + b, + `/transfer-requests/${request.json().requestId}/accept`, + 'POST', + {}, + randomUUID(), + ); + await f.call(b, `/spaces/${room.space.id}/members/${b.deviceId}`, 'DELETE'); + assert.equal( + f.service.db + .select() + .from(s.requests) + .where(eq(s.requests.id, request.json().requestId)) + .get().status, + 'cancelled', + ); + } finally { + await f.close(); + } +}); diff --git a/apps/server/test/vault.test.js b/apps/server/test/vault.test.js new file mode 100644 index 0000000..10b0794 --- /dev/null +++ b/apps/server/test/vault.test.js @@ -0,0 +1,409 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { fixture } from './helpers.js'; +import * as s from '../src/db/schema.js'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +async function signed(f, client, email) { + const id = randomUUID(); + f.service.db + .insert(s.users) + .values({ + id, + email, + displayName: email, + passwordHash: await f.service.auth.password('1234'), + status: 'active', + verifiedAt: Date.now(), + createdAt: Date.now(), + }) + .run(); + const login = await f.call(client, '/auth/login', 'POST', { email, password: '1234' }); + assert.equal(login.statusCode, 200, login.body); + client.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0]; + return id; +} +async function upload(f, client, id, bytes) { + const boundary = 'hop-vault-test'; + const payload = Buffer.concat([ + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="saved.txt"\r\nContent-Type: text/plain\r\n\r\n`, + ), + bytes, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]); + return f.call(client, `/uploads?requestId=${id}&fileIndex=0`, 'POST', payload, randomUUID(), { + 'content-type': `multipart/form-data; boundary=${boundary}`, + }); +} +function limits(f) { + f.cfg.sessionRate = 30; + f.cfg.pairRate = 30; + f.cfg.ipRate = 60; +} + +test('기기 수신은 로그인 계정의 날짜 기록에만 자동 저장, 익명 과거 자료와 로그아웃 후 도착은 가져오지 않음', async () => { + const f = await fixture(); + try { + limits(f); + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + await f.complete(a, b, 'https://example.com/before'); + await signed(f, b, 'receiver@hop.test'); + assert.equal((await f.call(b, '/inbox')).json().items.length, 0); + await f.complete(a, b, 'https://example.com/after'); + const item = (await f.call(b, '/inbox')).json().items[0]; + assert.equal(item.url, 'https://example.com/after'); + assert.equal(item.role, 'recipient'); + assert.equal((await f.call(b, '/vault')).json().items.length, 0); + assert(item.expiresAt - item.createdAt <= 7 * 86400000); + const guestItem = (await f.call(b, '/device/items')) + .json() + .items.find((i) => i.url === item.url); + await f.call(b, `/access/${guestItem.accessId}/read`, 'PATCH', { read: true }); + assert.equal((await f.call(b, '/inbox')).json().items[0].read, true); + const request = (await f.request(a, b, 'https://example.com/logged-out')).json().requestId; + await f.call(b, `/transfer-requests/${request}/accept`, 'POST', {}, randomUUID()); + await f.call(b, '/auth/logout', 'POST', {}); + await f.call( + a, + `/transfers/${request}/complete`, + 'POST', + { urls: ['https://example.com/logged-out'] }, + randomUUID(), + ); + const login = await f.call(b, '/auth/login', 'POST', { + email: 'receiver@hop.test', + password: '1234', + }); + b.cookie = + b.cookie.split('; hop_account=')[0] + + '; ' + + String(login.headers['set-cookie']).split(';')[0]; + assert.equal((await f.call(b, '/inbox')).json().items.length, 1); + } finally { + await f.close(); + } +}); + +test('임시 링크 보관: 메모, 7일, 중복 방지, 타인 접근 차단과 이메일 전송 제거', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await signed(f, a, 'owner@hop.test'); + await signed(f, b, 'other@hop.test'); + const key = randomUUID(), + input = { kind: 'url', urls: ['https://example.com/saved'], note: '나중에 읽을 글' }; + const result = await f.call(a, '/vault', 'POST', input, key); + assert.equal(result.statusCode, 200, result.body); + assert.equal( + (await f.call(a, '/vault', 'POST', input, key)).json().requestId, + result.json().requestId, + ); + const list = (await f.call(a, '/vault')).json().items; + assert.equal(list.length, 1); + const item = list[0]; + assert.equal(item.note, input.note); + assert.equal(item.expiresAt - item.createdAt, 7 * 86400000); + assert.equal((await f.call(a, '/inbox')).json().items.length, 0); + assert.equal( + (await f.call(b, `/vault/${item.accessId}/note`, 'PATCH', { note: 'stolen' })).statusCode, + 404, + ); + assert.equal( + (await f.call(a, `/vault/${item.accessId}/note`, 'PATCH', { note: '새 메모' })).statusCode, + 200, + ); + assert.equal((await f.call(a, '/vault?search=새')).json().items.length, 1); + assert.equal( + (await f.call(a, '/vault', 'POST', { ...input, note: '줄\n바꿈' }, randomUUID())).statusCode, + 400, + ); + assert.equal( + (await f.call(a, '/vault', 'POST', { kind: 'file', files: [] }, randomUUID())).statusCode, + 400, + ); + assert.equal( + ( + await f.call( + a, + '/vault', + 'POST', + { kind: 'url', urls: ['javascript:alert(1)'] }, + randomUUID(), + ) + ).statusCode, + 400, + ); + assert.equal( + ( + await f.call( + a, + '/transfer-requests', + 'POST', + { mode: 'email', email: 'other@hop.test', kind: 'url', count: 1, byteSize: 1 }, + randomUUID(), + ) + ).statusCode, + 410, + ); + } finally { + await f.close(); + } +}); + +test('보관함 링크 전송은 수락 전 비공개이며 수락 후 서버에서 전달, 삭제된 원본과 계정 세션 변경은 차단', async () => { + const f = await fixture(); + try { + limits(f); + const a = await f.create(), + b = await f.create(), + c = await f.create(); + await signed(f, a, 'owner@hop.test'); + await signed(f, b, 'receiver@hop.test'); + await signed(f, c, 'other@hop.test'); + await f.space(a, b); + await f.call( + a, + '/vault', + 'POST', + { kind: 'url', urls: ['https://example.com/private'] }, + randomUUID(), + ); + const item = (await f.call(a, '/vault')).json().items[0]; + assert.equal( + ( + await f.call( + c, + `/vault/${item.accessId}/send`, + 'POST', + { targetDeviceId: b.deviceId }, + randomUUID(), + ) + ).statusCode, + 404, + ); + const sent = await f.call( + a, + `/vault/${item.accessId}/send`, + 'POST', + { targetDeviceId: b.deviceId }, + randomUUID(), + ); + assert.equal(sent.statusCode, 200, sent.body); + assert.equal((await f.call(b, '/inbox')).json().items.length, 0); + const requests = (await f.call(b, '/transfer-requests')).json().requests; + assert(!JSON.stringify(requests).includes(item.url)); + assert.equal( + ( + await f.call( + b, + `/transfer-requests/${sent.json().requestId}/accept`, + 'POST', + {}, + randomUUID(), + ) + ).statusCode, + 200, + ); + assert.equal((await f.call(b, '/inbox')).json().items[0].url, item.url); + const next = await f.call( + a, + `/vault/${item.accessId}/send`, + 'POST', + { targetDeviceId: b.deviceId }, + randomUUID(), + ); + await f.call(a, '/access/delete-batch', 'POST', { ids: [item.accessId] }); + assert.equal( + ( + await f.call( + b, + `/transfer-requests/${next.json().requestId}/accept`, + 'POST', + {}, + randomUUID(), + ) + ).statusCode, + 404, + ); + f.service.cleanup(); + assert.equal((await f.call(b, '/inbox')).json().items.length, 1); + await f.call(a, `/transfer-requests/${next.json().requestId}/cancel`, 'POST', {}); + await f.call( + a, + '/vault', + 'POST', + { kind: 'url', urls: ['https://example.com/session'] }, + randomUUID(), + ); + const another = (await f.call(a, '/vault')).json().items[0]; + const revokedSource = await f.call( + a, + `/vault/${another.accessId}/send`, + 'POST', + { targetDeviceId: b.deviceId }, + randomUUID(), + ); + await f.call(a, '/auth/logout', 'POST', {}); + assert.equal( + ( + await f.call( + b, + `/transfer-requests/${revokedSource.json().requestId}/accept`, + 'POST', + {}, + randomUUID(), + ) + ).statusCode, + 401, + ); + assert.equal((await f.call(b, '/inbox')).json().items.length, 1); + } finally { + await f.close(); + } +}); + +test('보관함 파일은 저장된 바이트를 재사용, 수신 계정 기록·다운로드·원본 만료 후 상대 권한 유지와 마지막 참조 정리', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await signed(f, a, 'owner@hop.test'); + await signed(f, b, 'receiver@hop.test'); + await f.space(a, b); + const bytes = Buffer.from('stored vault file'); + const saved = await f.call( + a, + '/vault', + 'POST', + { + kind: 'file', + files: [{ name: 'saved.txt', size: bytes.length, type: 'text/plain' }], + note: '업무 자료', + }, + randomUUID(), + ); + assert.equal(saved.statusCode, 200, saved.body); + assert.equal((await upload(f, a, saved.json().requestId, bytes)).statusCode, 200); + const item = (await f.call(a, '/vault')).json().items[0]; + assert.equal(item.note, '업무 자료'); + const sent = await f.call( + a, + `/vault/${item.accessId}/send`, + 'POST', + { targetDeviceId: b.deviceId }, + randomUUID(), + ); + assert.equal(sent.statusCode, 200, sent.body); + assert.equal( + ( + await f.call( + b, + `/transfer-requests/${sent.json().requestId}/accept`, + 'POST', + {}, + randomUUID(), + ) + ).statusCode, + 200, + ); + const received = (await f.call(b, '/inbox')).json().items[0]; + assert.equal(received.file.id, item.file.id); + assert.deepEqual((await f.call(b, `/access/${received.accessId}/download`)).rawPayload, bytes); + assert.equal((await f.call(a, '/account/storage')).json().usedBytes, bytes.length); + const file = f.service.db.select().from(s.files).where(eq(s.files.id, item.file.id)).get(); + f.service.db + .update(s.items) + .set({ expiresAt: Date.now() - 1 }) + .where(eq(s.items.id, item.id)) + .run(); + f.service.cleanup(); + await f.service.storage.cleanup(); + assert.deepEqual((await f.call(b, `/access/${received.accessId}/download`)).rawPayload, bytes); + f.service.db + .update(s.items) + .set({ expiresAt: Date.now() - 1 }) + .run(); + f.service.cleanup(); + await f.service.storage.cleanup(); + assert.equal((await f.call(b, `/access/${received.accessId}/download`)).statusCode, 404); + assert.equal(existsSync(resolve(f.service.storage.originals, file.storageKey)), false); + assert.equal(f.service.db.select().from(s.items).all().length, 0); + f.service.db + .update(s.requests) + .set({ createdAt: Date.now() - 8 * 86400000, note: '만료된 메모', manifest: '[]' }) + .where(eq(s.requests.id, saved.json().requestId)) + .run(); + f.service.cleanup(); + const expired = f.service.db + .select() + .from(s.requests) + .where(eq(s.requests.id, saved.json().requestId)) + .get(); + assert.equal(expired.note, null); + assert.equal(expired.manifest, null); + } finally { + await f.close(); + } +}); + +test('보관함 콘텐츠에는 직접 저장과 받은 항목이 포함되며 자기 수신 기록도 같은 방 기기에 전달 가능', async () => { + const f = await fixture(); + try { + limits(f); + const a = await f.create(), + b = await f.create(), + c = await f.create(); + await signed(f, a, 'a@hop.test'); + await signed(f, b, 'b@hop.test'); + await signed(f, c, 'c@hop.test'); + await f.space(a, b); + await f.space(a, c); + await f.complete(a, b, 'https://example.com/received-source'); + await f.call( + b, + '/vault', + 'POST', + { kind: 'url', urls: ['https://example.com/saved-source'] }, + randomUUID(), + ); + const sources = (await f.call(b, '/vault/contents')).json().items; + assert.equal(sources.length, 2); + assert(sources.some((item) => item.role === 'recipient')); + assert(sources.some((item) => item.role === 'self')); + const received = sources.find((item) => item.role === 'recipient'); + const request = await f.call( + b, + `/vault/${received.accessId}/send`, + 'POST', + { targetDeviceId: c.deviceId }, + randomUUID(), + ); + assert.equal(request.statusCode, 200, request.body); + assert.equal( + ( + await f.call( + c, + `/transfer-requests/${request.json().requestId}/accept`, + 'POST', + {}, + randomUUID(), + ) + ).statusCode, + 200, + ); + assert.equal( + (await f.call(c, '/inbox')).json().items[0].url, + 'https://example.com/received-source', + ); + assert.equal((await f.call(a, '/vault/contents')).json().items.length, 0); + } finally { + await f.close(); + } +}); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..b4e62a4 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "allowJs": true + }, + "include": ["src"] +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..f7a34c2 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,14 @@ + + + + + + + hop. — 기기 사이를 가볍게. + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..feb7e63 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "@hop/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + }, + "dependencies": { + "@hop/shared": "0.1.0", + "@lucide/vue": "^1.53.0", + "qrcode": "^1.5.4", + "socket.io-client": "^4.8.3", + "vue": "^3.5.28" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.0", + "@vitejs/plugin-vue": "^6.0.3", + "tailwindcss": "^4.2.0", + "vite": "^7.3.1" + } +} diff --git a/apps/web/public/icon.svg b/apps/web/public/icon.svg new file mode 100644 index 0000000..5f6ccf1 --- /dev/null +++ b/apps/web/public/icon.svg @@ -0,0 +1 @@ + diff --git a/apps/web/src/AccountPanel.vue b/apps/web/src/AccountPanel.vue new file mode 100644 index 0000000..1aa8f8c --- /dev/null +++ b/apps/web/src/AccountPanel.vue @@ -0,0 +1,753 @@ + + diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue new file mode 100644 index 0000000..e264239 --- /dev/null +++ b/apps/web/src/App.vue @@ -0,0 +1,942 @@ + + + diff --git a/apps/web/src/SendComposer.vue b/apps/web/src/SendComposer.vue new file mode 100644 index 0000000..043e7c2 --- /dev/null +++ b/apps/web/src/SendComposer.vue @@ -0,0 +1,426 @@ + +