diff --git a/.env.example b/.env.example index 31a0aab..410e296 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,9 @@ DATABASE_URL=postgresql://doc:doc@localhost:5432/doc?schema=public # GitHub authentication (both values are required to enable the provider) AUTH_GITHUB_ID= AUTH_GITHUB_SECRET= +# Optional comma-separated numeric GitHub account IDs. Omit to permit GitHub signup. +# If present but empty or malformed, GitHub sign-in is denied. +# AUTH_GITHUB_ALLOWED_IDS=12345678 # Email authentication and notifications # The defaults use the local Mailpit service without credentials. diff --git a/.github/workflows/preview-container-images.yml b/.github/workflows/preview-container-images.yml new file mode 100644 index 0000000..d5b7f42 --- /dev/null +++ b/.github/workflows/preview-container-images.yml @@ -0,0 +1,86 @@ +name: Build Sealos preview images + +on: + push: + branches: ['preview/sealos-*'] + workflow_dispatch: + inputs: + app_url: + description: Public HTTPS origin compiled into the web image + type: string + required: true + collaboration_url: + description: Public WSS collaboration URL compiled into the web image + type: string + required: true + +permissions: + contents: read + +concurrency: + group: doc-preview-images-${{ github.ref }} + cancel-in-progress: true + +jobs: + images: + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + max-parallel: 1 + matrix: + include: + - image: doc-preview-web + file: Dockerfile + target: runner + - image: doc-preview-schema + file: Dockerfile + target: migrator + - image: doc-preview-collaboration + file: services/collaboration/Dockerfile + target: '' + env: + DOC_PREVIEW_APP_URL: ${{ inputs.app_url || vars.DOC_PREVIEW_APP_URL }} + DOC_PREVIEW_COLLABORATION_URL: ${{ inputs.collaboration_url || vars.DOC_PREVIEW_COLLABORATION_URL }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Require public build configuration + if: matrix.target == 'runner' + run: | + test -n "$DOC_PREVIEW_APP_URL" + test -n "$DOC_PREVIEW_COLLABORATION_URL" + case "$DOC_PREVIEW_APP_URL" in https://*) ;; *) exit 1 ;; esac + case "$DOC_PREVIEW_COLLABORATION_URL" in wss://*) ;; *) exit 1 ;; esac + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and publish commit image + id: build + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 + with: + context: . + file: ${{ matrix.file }} + target: ${{ matrix.target }} + platforms: linux/amd64 + push: true + tags: ghcr.io/bytefolk/${{ matrix.image }}:${{ github.sha }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + build-args: | + NEXT_PUBLIC_APP_URL=${{ env.DOC_PREVIEW_APP_URL }} + NEXT_PUBLIC_COLLABORATE_EDIT_URL=${{ env.DOC_PREVIEW_COLLABORATION_URL }} + cache-from: type=gha,scope=doc-preview + cache-to: type=gha,scope=doc-preview,mode=max + - name: Record image digest + env: + IMAGE_NAME: ${{ matrix.image }} + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: | + printf '%s\n' "ghcr.io/bytefolk/$IMAGE_NAME@$IMAGE_DIGEST" >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index 46b7129..47f7e61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,24 @@ All notable changes to `doc` are documented here. ## [Unreleased] +### Fixed + +- Allow explicitly opted-in managed PostgreSQL monitoring views during fresh + preview initialization only when their definitions, owners, dependencies and + extension membership match the same server's template database. Existing + application tables and unknown or altered views still prevent initialization. + ### Added +- Show GitHub account connection status in profile settings and let signed-in users + connect GitHub through the existing authenticated OAuth account-linking flow. +- Add an optional stable GitHub account-ID allowlist for private self-hosted previews. +- Require an expiring, single-use actor/session-bound intent when connecting a new + GitHub identity; retain only the provider subject rather than OAuth tokens. +- Add two-stage personal Sealos preview templates with guarded empty-database + initialization. Live Sealos deployment and GitHub OAuth remain unverified until + the operator completes the deployment and interactive sign-in checks. + - #86: Host Memory ranking abstains without a confirmed task intent. Overlay candidates drop `NONE` access via `resolveDocumentAccess`. `GET /api/v1/documents` returns list abstain copy in `meta.hostMemory`. Default advice payload never includes document bodies. Overlay does not change `shareRelations`. Refs #82. - Migrate the app to Next.js 16.3.5 / React 19.1.2: async `params` via diff --git a/Dockerfile b/Dockerfile index aabd6b5..c7bb586 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ RUN apk add --no-cache openssl FROM prisma-base AS migrator WORKDIR /app COPY prisma ./prisma +COPY services/collaboration/scripts/initialize-preview-database.mjs ./services/collaboration/scripts/initialize-preview-database.mjs CMD ["npm", "run", "db:push"] # Rebuild the source code only when needed diff --git a/deploy/sealos-preview/01-schema.template.yaml b/deploy/sealos-preview/01-schema.template.yaml new file mode 100644 index 0000000..89fbeab --- /dev/null +++ b/deploy/sealos-preview/01-schema.template.yaml @@ -0,0 +1,84 @@ +apiVersion: app.sealos.io/v1 +kind: Template +metadata: + name: doc-personal-schema +spec: + title: 'doc personal preview schema' + url: '' + gitRepo: 'https://github.com/bytefolk/doc' + author: 'ByteFolk' + description: 'Initialize a newly created empty doc preview database; deploy applications only after this Job succeeds.' + readme: '' + icon: '' + templateType: inline + locale: en + categories: [] + defaults: + app_name: + type: string + value: doc-schema-peterguy326-f5396ac8 + app_host: + type: string + value: doc-peterguy326-f5396ac8 + inputs: + migrator_image: + description: 'Pinned linux/amd64 migrator image from this checkout' + type: string + default: 'DOC_MIGRATOR_IMAGE' + required: true + runtime_secret: + description: 'Existing Secret containing DATABASE_URL for the new empty database' + type: string + default: 'doc-preview-secrets' + required: true +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: ${{ defaults.app_name }} + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + cloud.sealos.io/deploy-on-sealos: ${{ defaults.app_name }} +spec: + backoffLimit: 1 + template: + metadata: + labels: + app: ${{ defaults.app_name }} + spec: + automountServiceAccountToken: false + restartPolicy: Never + containers: + - name: ${{ defaults.app_name }} + image: ${{ inputs.migrator_image }} + imagePullPolicy: IfNotPresent + command: ['node', 'services/collaboration/scripts/initialize-preview-database.mjs'] + env: + - name: DOC_FRESH_PREVIEW_DATABASE + value: '1' + - name: DOC_FRESH_PREVIEW_ALLOW_MANAGED_MONITORING + value: '1' + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: ${{ inputs.runtime_secret }} + key: DATABASE_URL + resources: + requests: { cpu: 50m, memory: 51Mi } + limits: { cpu: 500m, memory: 512Mi } +--- +apiVersion: app.sealos.io/v1 +kind: App +metadata: + name: ${{ defaults.app_name }} + labels: + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + cloud.sealos.io/deploy-on-sealos: ${{ defaults.app_name }} +spec: + data: + url: https://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }} + displayType: normal + icon: '' + name: 'doc preview initialization' + type: link diff --git a/deploy/sealos-preview/02-apps.template.yaml b/deploy/sealos-preview/02-apps.template.yaml new file mode 100644 index 0000000..f11fca9 --- /dev/null +++ b/deploy/sealos-preview/02-apps.template.yaml @@ -0,0 +1,278 @@ +apiVersion: app.sealos.io/v1 +kind: Template +metadata: + name: doc-personal-preview +spec: + title: 'doc personal preview' + url: '' + gitRepo: 'https://github.com/bytefolk/doc' + author: 'ByteFolk' + description: 'Private GitHub-only doc web and realtime collaboration preview for PeterGuy326.' + readme: '' + icon: '' + templateType: inline + locale: en + categories: [] + defaults: + app_name: + type: string + value: doc-peterguy326-f5396ac8 + app_host: + type: string + value: doc-peterguy326-f5396ac8 + collaboration_host: + type: string + value: doc-collab-peterguy326-f5396ac8 + inputs: + web_image: + description: 'Pinned linux/amd64 web image built with the final public HTTPS/WSS URLs' + type: string + default: 'DOC_WEB_IMAGE' + required: true + collaboration_image: + description: 'Pinned linux/amd64 collaboration image' + type: string + default: 'DOC_COLLABORATION_IMAGE' + required: true + runtime_secret: + description: 'Existing Secret with DATABASE_URL, OAuth and shared collaboration keys' + type: string + default: 'doc-preview-secrets' + required: true +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${{ defaults.app_name }} + annotations: + originImageName: ${{ inputs.web_image }} + deploy.cloud.sealos.io/minReplicas: '1' + deploy.cloud.sealos.io/maxReplicas: '1' + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + cloud.sealos.io/deploy-on-sealos: ${{ defaults.app_name }} +spec: + replicas: 1 + revisionHistoryLimit: 1 + selector: + matchLabels: + app: ${{ defaults.app_name }} + template: + metadata: + labels: + app: ${{ defaults.app_name }} + spec: + automountServiceAccountToken: false + containers: + - name: ${{ defaults.app_name }} + image: ${{ inputs.web_image }} + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3000 + env: + - name: NODE_ENV + value: production + - name: DOC_PERSONAL_PREVIEW + value: '1' + - name: AUTH_GITHUB_ALLOWED_IDS + value: '47820304' + - name: AUTH_URL + value: https://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }} + - name: NEXTAUTH_URL + value: https://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }} + - name: NEXT_PUBLIC_APP_URL + value: https://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }} + - name: NEXT_PUBLIC_COLLABORATE_EDIT_URL + value: wss://${{ defaults.collaboration_host }}.${{ SEALOS_CLOUD_DOMAIN }}/collaborate + - name: COLLABORATE_EDIT_HTTP_URL + value: http://${{ defaults.app_name }}-collaboration.${{ SEALOS_NAMESPACE }}.svc.cluster.local:1234 + - name: DATABASE_URL + valueFrom: { secretKeyRef: { name: '${{ inputs.runtime_secret }}', key: DATABASE_URL } } + - name: AUTH_SECRET + valueFrom: { secretKeyRef: { name: '${{ inputs.runtime_secret }}', key: AUTH_SECRET } } + - name: AUTH_GITHUB_ID + valueFrom: { secretKeyRef: { name: '${{ inputs.runtime_secret }}', key: AUTH_GITHUB_ID } } + - name: AUTH_GITHUB_SECRET + valueFrom: { secretKeyRef: { name: '${{ inputs.runtime_secret }}', key: AUTH_GITHUB_SECRET } } + - name: COLLABORATE_API_AUTH_KEY + valueFrom: { secretKeyRef: { name: '${{ inputs.runtime_secret }}', key: COLLABORATE_API_AUTH_KEY } } + - name: COLLABORATE_INTERNAL_API_KEY + valueFrom: { secretKeyRef: { name: '${{ inputs.runtime_secret }}', key: COLLABORATE_INTERNAL_API_KEY } } + startupProbe: + httpGet: { path: /api/health, port: 3000 } + failureThreshold: 30 + periodSeconds: 5 + readinessProbe: + httpGet: { path: /api/health, port: 3000 } + livenessProbe: + tcpSocket: { port: 3000 } + resources: + requests: { cpu: 100m, memory: 102Mi } + limits: { cpu: '1', memory: 1024Mi } +--- +apiVersion: v1 +kind: Service +metadata: + name: ${{ defaults.app_name }} + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + cloud.sealos.io/deploy-on-sealos: ${{ defaults.app_name }} +spec: + selector: + app: ${{ defaults.app_name }} + ports: + - port: 3000 + targetPort: 3000 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ${{ defaults.app_name }} + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager-domain: ${{ defaults.app_host }} + cloud.sealos.io/deploy-on-sealos: ${{ defaults.app_name }} + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/proxy-body-size: 32m + nginx.ingress.kubernetes.io/ssl-redirect: 'true' + nginx.ingress.kubernetes.io/enable-access-log: 'false' + nginx.ingress.kubernetes.io/backend-protocol: HTTP + nginx.ingress.kubernetes.io/proxy-buffer-size: 64k + nginx.ingress.kubernetes.io/proxy-send-timeout: '300' + nginx.ingress.kubernetes.io/proxy-read-timeout: '300' +spec: + rules: + - host: ${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }} + http: + paths: + - pathType: Prefix + path: / + backend: + service: + name: ${{ defaults.app_name }} + port: { number: 3000 } + tls: + - hosts: ['${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }}'] + secretName: ${{ SEALOS_CERT_SECRET_NAME }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${{ defaults.app_name }}-collaboration + annotations: + originImageName: ${{ inputs.collaboration_image }} + deploy.cloud.sealos.io/minReplicas: '1' + deploy.cloud.sealos.io/maxReplicas: '1' + labels: + app: ${{ defaults.app_name }}-collaboration + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }}-collaboration + cloud.sealos.io/deploy-on-sealos: ${{ defaults.app_name }} +spec: + replicas: 1 + revisionHistoryLimit: 1 + selector: + matchLabels: + app: ${{ defaults.app_name }}-collaboration + template: + metadata: + labels: + app: ${{ defaults.app_name }}-collaboration + spec: + automountServiceAccountToken: false + containers: + - name: ${{ defaults.app_name }}-collaboration + image: ${{ inputs.collaboration_image }} + imagePullPolicy: IfNotPresent + ports: + - containerPort: 1234 + env: + - name: NODE_ENV + value: production + - name: PORT + value: '1234' + - name: DOC_WEB_INTERNAL_URL + value: http://${{ defaults.app_name }}.${{ SEALOS_NAMESPACE }}.svc.cluster.local:3000 + - name: DATABASE_URL + valueFrom: { secretKeyRef: { name: '${{ inputs.runtime_secret }}', key: DATABASE_URL } } + - name: API_AUTH_KEY + valueFrom: { secretKeyRef: { name: '${{ inputs.runtime_secret }}', key: COLLABORATE_API_AUTH_KEY } } + - name: INTERNAL_API_KEY + valueFrom: { secretKeyRef: { name: '${{ inputs.runtime_secret }}', key: COLLABORATE_INTERNAL_API_KEY } } + - name: COLLABORATE_INTERNAL_API_KEY + valueFrom: { secretKeyRef: { name: '${{ inputs.runtime_secret }}', key: COLLABORATE_INTERNAL_API_KEY } } + startupProbe: + httpGet: { path: /ready, port: 1234 } + failureThreshold: 30 + periodSeconds: 5 + readinessProbe: + httpGet: { path: /ready, port: 1234 } + livenessProbe: + tcpSocket: { port: 1234 } + resources: + requests: { cpu: 50m, memory: 51Mi } + limits: { cpu: 500m, memory: 512Mi } +--- +apiVersion: v1 +kind: Service +metadata: + name: ${{ defaults.app_name }}-collaboration + labels: + app: ${{ defaults.app_name }}-collaboration + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }}-collaboration + cloud.sealos.io/deploy-on-sealos: ${{ defaults.app_name }} +spec: + selector: + app: ${{ defaults.app_name }}-collaboration + ports: + - port: 1234 + targetPort: 1234 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ${{ defaults.app_name }}-collaboration + labels: + app: ${{ defaults.app_name }}-collaboration + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }}-collaboration + cloud.sealos.io/app-deploy-manager-domain: ${{ defaults.collaboration_host }} + cloud.sealos.io/deploy-on-sealos: ${{ defaults.app_name }} + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/ssl-redirect: 'true' + nginx.ingress.kubernetes.io/enable-access-log: 'false' + nginx.ingress.kubernetes.io/backend-protocol: WS + nginx.ingress.kubernetes.io/proxy-send-timeout: '3600' + nginx.ingress.kubernetes.io/proxy-read-timeout: '3600' +spec: + rules: + - host: ${{ defaults.collaboration_host }}.${{ SEALOS_CLOUD_DOMAIN }} + http: + paths: + - pathType: Prefix + path: / + backend: + service: + name: ${{ defaults.app_name }}-collaboration + port: { number: 1234 } + tls: + - hosts: ['${{ defaults.collaboration_host }}.${{ SEALOS_CLOUD_DOMAIN }}'] + secretName: ${{ SEALOS_CERT_SECRET_NAME }} +--- +apiVersion: app.sealos.io/v1 +kind: App +metadata: + name: ${{ defaults.app_name }} + labels: + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + cloud.sealos.io/deploy-on-sealos: ${{ defaults.app_name }} +spec: + data: + url: https://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }} + displayType: normal + icon: '' + name: 'doc personal preview' + type: link diff --git a/deploy/sealos-preview/README.md b/deploy/sealos-preview/README.md new file mode 100644 index 0000000..994c1bb --- /dev/null +++ b/deploy/sealos-preview/README.md @@ -0,0 +1,124 @@ +# Personal Sealos preview + +This deployment is a disposable personal preview, built from this checkout. It +restricts GitHub sign-in to the stable numeric account ID `47820304` and has no +email provider or public Mailpit. The GitHub OAuth application must have its +callback set to `https:///api/auth/callback/github`. + +`DOC_PERSONAL_PREVIEW=1` also denies GitHub sign-in if its allowlist is accidentally +omitted, and rejects other providers. Account connection requires a five-minute, +single-use intent bound to the initiating actor and session; switching accounts +or signing out before the callback rejects the connection. OAuth access/refresh +tokens are not retained for this sign-in-only integration. + +## Images + +Build the web image with its final HTTPS and WSS URLs. `NEXT_PUBLIC_*` values are +compiled into the browser bundle; changing only runtime environment variables +does not change the collaboration URL. + +```sh +docker build --platform linux/amd64 --target runner -t "$DOC_WEB_IMAGE" \ + --build-arg NEXT_PUBLIC_APP_URL="https://$DOC_WEB_HOST" \ + --build-arg NEXT_PUBLIC_COLLABORATE_EDIT_URL="wss://$DOC_COLLABORATION_HOST/collaborate" . +docker build --platform linux/amd64 --target migrator -t "$DOC_MIGRATOR_IMAGE" . +docker build --platform linux/amd64 -f services/collaboration/Dockerfile -t "$DOC_COLLABORATION_IMAGE" . +``` + +The build never needs OAuth credentials. Supply them only to the web container +through a platform Secret. + +The preview image workflow runs only on `preview/sealos-*` branches or explicit +dispatch. Set the non-secret repository variables `DOC_PREVIEW_APP_URL` and +`DOC_PREVIEW_COLLABORATION_URL` before pushing that branch. The pinned images are +`ghcr.io/bytefolk/doc-preview-{web,schema,collaboration}:`; the workflow +records their immutable digests after a successful run. A workflow file in this +checkout does not by itself confirm that an image has been built or published. + +## Runtime configuration + +Create a `doc-preview-secrets` Secret in the target namespace, without committing +its contents. Required keys: + +- `DATABASE_URL`: PostgreSQL connection string shared by all three workloads. +- `AUTH_SECRET`: a random session encryption secret, at least 32 bytes. +- `AUTH_GITHUB_ID` and `AUTH_GITHUB_SECRET`: a GitHub OAuth application. +- `COLLABORATE_API_AUTH_KEY`: a random encryption key shared by web and collaboration. +- `COLLABORATE_INTERNAL_API_KEY`: a separate random service authentication key. + +The templates default to anonymous pulls of public images and contain no pull +credential. Publishing images does not automatically make GHCR packages public; +the operator must explicitly approve and confirm that visibility before deploying +the defaults. Keep PostgreSQL private and persistent. + +For private images, create `doc-preview-ghcr` as a pull Secret using a credential +with `read:packages` and access to all three packages. Add this fragment to each +workload's `spec.template.spec` (the schema Job and both Deployments) before +submitting the templates: + +```yaml +imagePullSecrets: + - name: doc-preview-ghcr +``` + +A GitHub CLI token without `read:packages` is not a usable GHCR pull credential. +The runtime Secret names remain inputs; no YAML contains secret values. + +The two templates use the official Sealos Template envelope, platform-generated +namespace/domain/certificate variables, workload manager labels, full Service +FQDNs and the supported CPU/memory ladder. Their defaults allocate these hosts +in the Hangzhou cluster; verify availability before building or deploying: + +- `https://doc-peterguy326-f5396ac8.hzh.sealos.run` +- `wss://doc-collab-peterguy326-f5396ac8.hzh.sealos.run/collaborate` + +`02-apps.template.yaml` creates both HTTPS routes, with long-lived WebSocket +connections on the collaboration ingress. Access logs are disabled to keep OAuth +callback query strings out of ingress access logs. The web workload limits are +`1` CPU / `1024Mi` memory (requests `100m` / `102Mi`); collaboration and schema +limits are each `500m` / `512Mi` (requests `50m` / `51Mi`). Check the namespace +quota before applying. + +`AUTH_URL`, `NEXTAUTH_URL` and `NEXT_PUBLIC_APP_URL` must agree on the public web +origin. Do not configure `EMAIL_*` or `RESEND_API_KEY` for this GitHub-only preview. +Do not enable `allowDangerousEmailAccountLinking`. + +## Empty database initialization + +Submit `01-schema.template.yaml` through the Template API **only against a newly +created, empty preview database**, with `migrator_image` set to the pinned schema +image. Wait for Job `doc-schema-peterguy326-f5396ac8` to complete successfully. +Inspect a failed Job before retrying; the initializer intentionally refuses an +already initialized database. + +Only after that success, submit `02-apps.template.yaml` through the Template API +with `web_image` and `collaboration_image` set to the pinned images. Do not submit +both stages concurrently or treat document order as a dependency guarantee. +The initializer requires `DOC_FRESH_PREVIEW_DATABASE=1` and rejects a database +containing any user table or view before invoking `npm run db:push`. It includes the share +uniqueness preflight and document search-index setup. The checked-in migrations +do not yet provide a complete baseline for `prisma migrate deploy`. + +This is not a production upgrade procedure. Keep a persistent PostgreSQL volume +for the preview; application pods do not own the document data. + +## Verification + +1. `/api/health` returns database `ok`; collaboration `/ready` does likewise. +2. `/api/auth/providers` contains GitHub and no email provider. A sign-in request + redirects to GitHub with the exact registered HTTPS callback. +3. The operator signs in as `PeterGuy326`; another GitHub numeric ID is denied by + the server callback. OAuth approval remains an interactive user step. +4. Profile settings show `GitHub is connected`; `/api/account-connections` denies + anonymous requests and returns no OAuth access or refresh token. +5. Create a document, edit it, reload it, and confirm PostgreSQL persistence and + WebSocket collaboration. The text editor does not require AI credentials. + +Image upload currently uses the legacy Alibaba OSS route, so it requires the +`OSS_*` credentials and CDN hostname; the separate generic storage provider is +not yet wired into that route. Leave uploads unconfigured for this text preview +unless a compatible storage configuration is provided. + +Live Sealos deployment, the image workflow, and interactive GitHub OAuth have not +been verified by preparing these files. Record their actual results after the +operator completes the checks above. diff --git a/messages/en.json b/messages/en.json index 8344afd..26318d6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -88,6 +88,17 @@ "profileTitle": "Complete your profile", "profileDescription": "Choose the name collaborators will see, then continue to your workspace." }, + "accountConnections": { + "title": "GitHub account", + "description": "Connect GitHub to sign in to this same doc account. Your documents stay with your current account.", + "loading": "Loading account connection…", + "connected": "GitHub is connected.", + "connect": "Connect GitHub", + "connecting": "Connecting…", + "unavailable": "GitHub sign-in is not configured for this instance.", + "loadFailed": "Could not load the account connection.", + "connectFailed": "Could not connect GitHub. Please try again." + }, "personalAccessTokens": { "title": "Personal access tokens", "description": "Create scoped credentials for doc API and CLI access. Tokens expire automatically and can be revoked at any time.", diff --git a/messages/zh-cn.json b/messages/zh-cn.json index 1f789b0..ef832fc 100644 --- a/messages/zh-cn.json +++ b/messages/zh-cn.json @@ -88,6 +88,17 @@ "profileTitle": "完善个人资料", "profileDescription": "设置协作者可见的名称,然后进入工作区。" }, + "accountConnections": { + "title": "GitHub 账号", + "description": "连接 GitHub 后,可用它登录当前 doc 账号。你的文档仍属于当前账号。", + "loading": "正在加载账号连接…", + "connected": "已连接 GitHub。", + "connect": "连接 GitHub", + "connecting": "连接中…", + "unavailable": "当前实例尚未配置 GitHub 登录。", + "loadFailed": "账号连接加载失败。", + "connectFailed": "GitHub 连接失败,请重试。" + }, "personalAccessTokens": { "title": "个人访问令牌", "description": "为 doc API 和 CLI 创建限定权限的凭证。令牌会自动过期,也可以随时撤销。", diff --git a/services/collaboration/scripts/initialize-preview-database.mjs b/services/collaboration/scripts/initialize-preview-database.mjs new file mode 100644 index 0000000..4b69b36 --- /dev/null +++ b/services/collaboration/scripts/initialize-preview-database.mjs @@ -0,0 +1,138 @@ +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import pg from 'pg' + +// Names and extension memberships were confirmed on the managed server. This +// list never exempts tables or an entire namespace. Canonical definitions, +// owners and dependencies must also match that server's template1 below. +const monitoringViews = new Map([ + ['public.pg_stat_statements', 'pg_stat_statements'], + ['public.pg_stat_statements_info', 'pg_stat_statements'], + ['public.pg_stat_kcache', 'pg_stat_kcache'], + ['public.pg_stat_kcache_detail', 'pg_stat_kcache'], + ['metric_helpers.index_bloat', null], + ['metric_helpers.nearly_exhausted_sequences', null], + ['metric_helpers.pg_stat_statements', null], + ['metric_helpers.table_bloat', null], +]) + +const relationMetadataQuery = ` + SELECT namespace.nspname AS schema_name, relation.relname AS relation_name, + relation.relkind AS kind, pg_get_userbyid(relation.relowner) AS owner_name, + CASE WHEN relation.relkind = 'v' THEN pg_get_viewdef(relation.oid, true) END AS definition, + extension.extname AS extension_name, + COALESCE(( + SELECT json_agg(pg_describe_object(dependency.refclassid, dependency.refobjid, dependency.refobjsubid) + ORDER BY pg_describe_object(dependency.refclassid, dependency.refobjid, dependency.refobjsubid)) + FROM pg_rewrite AS rewrite + JOIN pg_depend AS dependency ON dependency.classid = 'pg_rewrite'::regclass + AND dependency.objid = rewrite.oid AND dependency.deptype = 'n' + WHERE rewrite.ev_class = relation.oid + AND NOT (dependency.refclassid = 'pg_class'::regclass AND dependency.refobjid = relation.oid) + ), '[]'::json) AS dependencies + FROM pg_class AS relation + JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace + LEFT JOIN pg_depend AS extension_dependency ON extension_dependency.classid = 'pg_class'::regclass + AND extension_dependency.objid = relation.oid + AND extension_dependency.refclassid = 'pg_extension'::regclass AND extension_dependency.deptype = 'e' + LEFT JOIN pg_extension AS extension ON extension.oid = extension_dependency.refobjid + WHERE namespace.nspname !~ '^pg_' AND namespace.nspname <> 'information_schema' + AND relation.relkind IN ('r', 'p', 'v', 'm', 'f') + ORDER BY namespace.nspname, relation.relname +` + +function isMonitoringCandidate(relation) { + const key = `${relation.schema_name}.${relation.relation_name}` + return ( + relation.kind === 'v' && + monitoringViews.has(key) && + relation.extension_name === monitoringViews.get(key) && + typeof relation.owner_name === 'string' && + typeof relation.definition === 'string' && + Array.isArray(relation.dependencies) + ) +} + +function monitoringFingerprint(relation) { + return createHash('sha256') + .update( + JSON.stringify({ + owner: relation.owner_name, + definition: relation.definition, + extension: relation.extension_name, + dependencies: relation.dependencies, + }) + ) + .digest('hex') +} + +export async function initializeFreshPreviewDatabase({ + environment = process.env, + createClient = (connectionString) => new pg.Client({ connectionString }), + runCommand = () => { + const result = spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'db:push'], { + stdio: 'inherit', + env: environment, + shell: process.platform === 'win32', + }) + return result.status ?? 1 + }, +} = {}) { + if (environment.DOC_FRESH_PREVIEW_DATABASE !== '1') { + throw new Error('Explicit fresh-preview database initialization is required') + } + if (!environment.DATABASE_URL) throw new Error('Preview database is not configured') + const databaseUrl = new URL(environment.DATABASE_URL) + const databaseName = decodeURIComponent(databaseUrl.pathname).slice(1).toLowerCase() + if (!databaseName || ['template0', 'template1', 'postgres'].includes(databaseName)) { + throw new Error('A template or maintenance database cannot be initialized') + } + + const client = createClient(environment.DATABASE_URL) + try { + await client.connect() + const { rows: relations } = await client.query(relationMetadataQuery) + if (relations.length) { + if ( + environment.DOC_FRESH_PREVIEW_ALLOW_MANAGED_MONITORING !== '1' || + relations.some((relation) => !isMonitoringCandidate(relation)) + ) { + throw new Error('Preview initialization requires an empty database') + } + const baselineUrl = new URL(databaseUrl) + baselineUrl.pathname = '/template1' + const baselineClient = createClient(baselineUrl.toString()) + try { + await baselineClient.connect() + const { rows: baseline } = await baselineClient.query(relationMetadataQuery) + const baselineByName = new Map( + baseline + .filter(isMonitoringCandidate) + .map((relation) => [`${relation.schema_name}.${relation.relation_name}`, relation]) + ) + for (const relation of relations) { + const reference = baselineByName.get(`${relation.schema_name}.${relation.relation_name}`) + if (!reference || monitoringFingerprint(reference) !== monitoringFingerprint(relation)) { + throw new Error('Preview monitoring views differ from the managed template database') + } + } + } finally { + await baselineClient.end() + } + } + } finally { + await client.end() + } + + if (runCommand() !== 0) throw new Error('Preview schema initialization failed') +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + initializeFreshPreviewDatabase().catch(() => { + // Connection errors can contain credentials. Keep deployment logs bounded. + console.error('doc preview schema initialization failed; verify the fresh database and configuration') + process.exitCode = 1 + }) +} diff --git a/src/__tests__/api/account-connections.test.ts b/src/__tests__/api/account-connections.test.ts new file mode 100644 index 0000000..7d18edb --- /dev/null +++ b/src/__tests__/api/account-connections.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ getUserInfo: vi.fn(), findFirst: vi.fn() })) +vi.mock('@/lib/session', () => ({ getUserInfo: mocks.getUserInfo })) +vi.mock('@/db/db', () => ({ db: { account: { findFirst: mocks.findFirst } } })) + +import { GET } from '@/app/api/account-connections/route' + +describe('account connections', () => { + beforeEach(() => { + vi.resetAllMocks() + mocks.getUserInfo.mockResolvedValue({ id: 'user-1' }) + mocks.findFirst.mockResolvedValue(null) + vi.stubEnv('AUTH_GITHUB_ID', 'configured-client') + vi.stubEnv('AUTH_GITHUB_SECRET', 'configured-secret') + }) + afterEach(() => vi.unstubAllEnvs()) + + it('requires a session before reading any provider accounts', async () => { + mocks.getUserInfo.mockResolvedValue(null) + const response = await GET() + expect(response.status).toBe(401) + expect(mocks.findFirst).not.toHaveBeenCalled() + }) + + it('selects only the current user and returns no provider credentials', async () => { + mocks.findFirst.mockResolvedValue({ provider: 'github', access_token: 'must-not-leak' }) + const response = await GET() + expect(mocks.findFirst).toHaveBeenCalledWith({ + where: { userId: 'user-1', provider: 'github' }, + select: { provider: true }, + }) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(await response.json()).toEqual({ data: { github: { enabled: true, connected: true } } }) + }) + + it('does not offer connection with partially configured OAuth', async () => { + vi.stubEnv('AUTH_GITHUB_SECRET', '') + expect(await (await GET()).json()).toEqual({ data: { github: { enabled: false, connected: false } } }) + }) + + it('reports unavailable instead of claiming an account is disconnected after database failure', async () => { + mocks.findFirst.mockRejectedValue(new Error('private connection details')) + const response = await GET() + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + error: { code: 'unavailable', message: 'Could not load connected accounts' }, + }) + }) +}) diff --git a/src/__tests__/api/github-link-start.test.ts b/src/__tests__/api/github-link-start.test.ts new file mode 100644 index 0000000..891ab7a --- /dev/null +++ b/src/__tests__/api/github-link-start.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ auth: vi.fn(), findUnique: vi.fn(), issue: vi.fn(), setCookie: vi.fn() })) +vi.mock('server-only', () => ({})) +vi.mock('auth', () => ({ auth: mocks.auth })) +vi.mock('@/db/db', () => ({ db: { user: { findUnique: mocks.findUnique } } })) +vi.mock('@/lib/session', () => ({ getUserInfo: vi.fn() })) +vi.mock('next/headers', () => ({ + cookies: async () => ({ getAll: () => [{ name: 'authjs.session-token', value: 'session' }], set: mocks.setCookie }), +})) +vi.mock('@/lib/github-account-link', () => ({ + GITHUB_LINK_COOKIE: 'doc.github-link-intent', + issueGitHubLinkIntent: mocks.issue, +})) + +import { POST } from '@/app/api/account-connections/github/route' + +function request(origin = 'https://doc.test') { + return new Request('https://doc.test/api/account-connections/github', { method: 'POST', headers: { origin } }) +} + +describe('GitHub account-link start', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.stubEnv('AUTH_URL', 'https://doc.test') + vi.stubEnv('NEXTAUTH_URL', 'https://doc.test') + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://doc.test') + vi.stubEnv('AUTH_GITHUB_ID', 'configured-client') + vi.stubEnv('AUTH_GITHUB_SECRET', 'configured-secret') + mocks.auth.mockResolvedValue({ user: { id: 'actor-a' } }) + mocks.findUnique.mockResolvedValue({ id: 'actor-a' }) + mocks.issue.mockResolvedValue('signed-intent') + }) + afterEach(() => vi.unstubAllEnvs()) + + it('requires same-origin intent and an authenticated actor', async () => { + expect((await POST(request('https://other.test'))).status).toBe(403) + expect(mocks.issue).not.toHaveBeenCalled() + mocks.auth.mockResolvedValue(null) + expect((await POST(request())).status).toBe(401) + expect(mocks.issue).not.toHaveBeenCalled() + }) + + it('binds to the stable session user and stores an HttpOnly HTTPS cookie', async () => { + const response = await POST(request()) + expect(response.status).toBe(200) + expect(mocks.findUnique).toHaveBeenCalledWith({ where: { id: 'actor-a' }, select: { id: true } }) + expect(mocks.issue).toHaveBeenCalledWith('actor-a', [{ name: 'authjs.session-token', value: 'session' }]) + expect(mocks.setCookie).toHaveBeenCalledWith('doc.github-link-intent', 'signed-intent', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 900, + }) + expect(await response.json()).toEqual({ data: { ready: true } }) + }) + + it('does not issue an intent for unconfigured OAuth', async () => { + vi.stubEnv('AUTH_GITHUB_SECRET', '') + expect((await POST(request())).status).toBe(503) + expect(mocks.issue).not.toHaveBeenCalled() + }) +}) diff --git a/src/__tests__/components/github-account-connection.test.tsx b/src/__tests__/components/github-account-connection.test.tsx new file mode 100644 index 0000000..55fe1c9 --- /dev/null +++ b/src/__tests__/components/github-account-connection.test.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import GitHubAccountConnection from '@/components/github-account-connection' +import NextIntlClientProviderWrapper from '../utils/next-intl-client-provider-wrapper' + +const authMocks = vi.hoisted(() => ({ signIn: vi.fn() })) +vi.mock('next-auth/react', () => authMocks) + +function renderConnection() { + return render( + + + + ) +} + +describe('GitHub account connection', () => { + beforeEach(() => authMocks.signIn.mockReset()) + afterEach(() => vi.unstubAllGlobals()) + + it('starts the existing OAuth flow while retaining the signed-in account', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ data: { github: { enabled: true, connected: false } } })) + ) + renderConnection() + fireEvent.click(await screen.findByRole('button', { name: 'Connect GitHub' })) + await waitFor(() => expect(authMocks.signIn).toHaveBeenCalledWith('github', { callbackUrl: '/en/user-info' })) + }) + + it('shows the persisted connected state without offering a second connection', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ data: { github: { enabled: true, connected: true } } })) + ) + renderConnection() + expect(await screen.findByRole('status')).toHaveProperty('textContent', 'GitHub is connected.') + expect(screen.queryByRole('button', { name: 'Connect GitHub' })).toBeNull() + }) + + it('explains unavailable OAuth without presenting a broken connection action', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ data: { github: { enabled: false, connected: false } } })) + ) + renderConnection() + expect(await screen.findByText('GitHub sign-in is not configured for this instance.')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Connect GitHub' })).toBeNull() + }) + + it('reports lookup failure instead of offering to connect an unknown account', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 503 }))) + renderConnection() + expect((await screen.findByRole('alert')).textContent).toBe('Could not load the account connection.') + expect(screen.queryByRole('button', { name: 'Connect GitHub' })).toBeNull() + }) + + it('does not start OAuth when an authenticated linking intent cannot be issued', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(Response.json({ data: { github: { enabled: true, connected: false } } })) + .mockResolvedValueOnce(new Response(null, { status: 401 })) + ) + renderConnection() + fireEvent.click(await screen.findByRole('button', { name: 'Connect GitHub' })) + expect((await screen.findByRole('alert')).textContent).toBe('Could not connect GitHub. Please try again.') + expect(authMocks.signIn).not.toHaveBeenCalled() + }) +}) diff --git a/src/__tests__/lib/auth-configuration.test.ts b/src/__tests__/lib/auth-configuration.test.ts index d9fdfe3..2f28d9c 100644 --- a/src/__tests__/lib/auth-configuration.test.ts +++ b/src/__tests__/lib/auth-configuration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { resolveAuthConfiguration } from '@/lib/auth-configuration' +import { isGitHubAccountAllowed, resolveAuthConfiguration } from '@/lib/auth-configuration' describe('authentication provider configuration', () => { it('does not expose providers with partial configuration', () => { @@ -50,3 +50,24 @@ describe('authentication provider configuration', () => { }) }) }) + +describe('private GitHub account allowlist', () => { + it('preserves open signup when an allowlist is not configured', () => { + expect(isGitHubAccountAllowed('47820304', {})).toBe(true) + expect(isGitHubAccountAllowed('47820304', { DOC_PERSONAL_PREVIEW: '1' })).toBe(false) + }) + + it('matches stable account IDs and rejects a different account or missing identity', () => { + const env = { AUTH_GITHUB_ALLOWED_IDS: '47820304, 12345678' } + expect(isGitHubAccountAllowed('47820304', env)).toBe(true) + expect(isGitHubAccountAllowed('12345678', env)).toBe(true) + expect(isGitHubAccountAllowed('99999999', env)).toBe(false) + expect(isGitHubAccountAllowed(undefined, env)).toBe(false) + }) + + it('fails closed for an explicitly empty or malformed allowlist', () => { + for (const value of ['', ' ', 'PeterGuy326', '47820304,']) { + expect(isGitHubAccountAllowed('47820304', { AUTH_GITHUB_ALLOWED_IDS: value })).toBe(false) + } + }) +}) diff --git a/src/__tests__/lib/github-account-link.test.ts b/src/__tests__/lib/github-account-link.test.ts new file mode 100644 index 0000000..cae9779 --- /dev/null +++ b/src/__tests__/lib/github-account-link.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ create: vi.fn(), deleteMany: vi.fn(), findUnique: vi.fn(), findUser: vi.fn() })) +vi.mock('@/db/db', () => ({ + db: { + verificationToken: { create: mocks.create, deleteMany: mocks.deleteMany }, + account: { findUnique: mocks.findUnique }, + user: { findUnique: mocks.findUser }, + }, +})) + +import { + authSessionDigest, + authorizeGitHubLink, + consumeGitHubLinkIntent, + GITHUB_LINK_COOKIE, + issueGitHubLinkIntent, +} from '@/lib/github-account-link' + +const session = [{ name: '__Secure-authjs.session-token', value: 'actor-a-session' }] + +describe('explicit GitHub linking intent', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.stubEnv('AUTH_SECRET', 'test-only-signing-secret-for-account-linking') + mocks.create.mockResolvedValue({}) + mocks.deleteMany.mockResolvedValue({ count: 1 }) + mocks.findUnique.mockResolvedValue(null) + mocks.findUser.mockResolvedValue({ id: 'actor-a' }) + }) + afterEach(() => { + vi.unstubAllEnvs() + vi.useRealTimers() + }) + + it('binds the intent to the actor/session and consumes its server record once', async () => { + const intent = await issueGitHubLinkIntent('actor-a', session) + expect(await consumeGitHubLinkIntent(intent, 'actor-a', session)).toBe(true) + const stored = mocks.create.mock.calls[0][0].data + expect(stored.identifier).toBe('github-link:actor-a') + expect(stored.token).toMatch(/^[a-f0-9]{64}$/) + mocks.deleteMany.mockResolvedValue({ count: 0 }) + expect(await consumeGitHubLinkIntent(intent, 'actor-a', session)).toBe(false) + }) + + it('rejects account switching, logout and session rotation before any consume or link', async () => { + const intent = await issueGitHubLinkIntent('actor-a', session) + expect(await consumeGitHubLinkIntent(intent, 'actor-b', session)).toBe(false) + expect(await consumeGitHubLinkIntent(intent, undefined, [])).toBe(false) + expect(await consumeGitHubLinkIntent(intent, 'actor-a', [{ ...session[0], value: 'new-session' }])).toBe(false) + expect(mocks.deleteMany).not.toHaveBeenCalled() + }) + + it('rejects modified and expired challenges', async () => { + vi.useFakeTimers() + const intent = await issueGitHubLinkIntent('actor-a', session) + expect(await consumeGitHubLinkIntent(`${intent}x`, 'actor-a', session)).toBe(false) + vi.advanceTimersByTime(5 * 60 * 1000) + expect(await consumeGitHubLinkIntent(intent, 'actor-a', session)).toBe(false) + expect(mocks.deleteMany).not.toHaveBeenCalled() + }) + + it('requires explicit intent to link a new subject while logged in', async () => { + expect(await authorizeGitHubLink('47820304', 'actor-a', session)).toBe(false) + mocks.findUnique.mockResolvedValue({ userId: 'actor-b' }) + expect(await authorizeGitHubLink('47820304', 'actor-a', session)).toBe(false) + mocks.findUnique.mockResolvedValue({ userId: 'actor-a' }) + expect(await authorizeGitHubLink('47820304', 'actor-a', session)).toBe(true) + }) + + it('permits ordinary anonymous login but never downgrades an interrupted link into login', async () => { + expect(await authorizeGitHubLink('47820304', undefined, [])).toBe(true) + const intent = await issueGitHubLinkIntent('actor-a', session) + expect(await authorizeGitHubLink('47820304', undefined, [{ name: GITHUB_LINK_COOKIE, value: intent }])).toBe(false) + }) + + it('does not recreate an actor deleted while OAuth was in progress', async () => { + const intent = await issueGitHubLinkIntent('actor-a', session) + mocks.findUser.mockResolvedValue(null) + expect( + await authorizeGitHubLink('47820304', 'actor-a', [...session, { name: GITHUB_LINK_COOKIE, value: intent }]) + ).toBe(false) + expect(mocks.deleteMany).not.toHaveBeenCalled() + }) + + it('hashes ordered cookie chunks and rejects missing or ambiguous chunks', () => { + const chunks = [ + { name: '__Secure-authjs.session-token.1', value: 'session' }, + { name: '__Secure-authjs.session-token.0', value: 'actor-a-' }, + ] + expect(authSessionDigest(chunks)).toBe(authSessionDigest(session)) + expect(authSessionDigest(chunks.slice(0, 1))).toBeNull() + expect(authSessionDigest([...chunks, session[0]])).toBeNull() + expect(authSessionDigest([])).toBeNull() + }) +}) diff --git a/src/__tests__/lib/github-identity.test.ts b/src/__tests__/lib/github-identity.test.ts new file mode 100644 index 0000000..ece8edd --- /dev/null +++ b/src/__tests__/lib/github-identity.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { fetchVerifiedGitHubProfile } from '@/lib/github-identity' + +function responses(emails: unknown) { + return vi + .fn() + .mockResolvedValueOnce( + Response.json({ id: 47820304, login: 'PeterGuy326', email: 'unverified-public@example.test' }) + ) + .mockResolvedValueOnce(Response.json(emails)) +} + +describe('verified GitHub identity', () => { + it('uses only the primary verified email, overriding the public profile field', async () => { + const fetcher = responses([ + { email: 'secondary@example.test', primary: false, verified: true }, + { email: 'verified@example.test', primary: true, verified: true }, + ]) + expect(await fetchVerifiedGitHubProfile('synthetic-token', fetcher)).toMatchObject({ + id: 47820304, + email: 'verified@example.test', + }) + expect(fetcher).toHaveBeenCalledWith( + 'https://api.github.com/user', + expect.objectContaining({ redirect: 'error', signal: expect.any(AbortSignal) }) + ) + }) + + it('rejects unverified primary and verified secondary emails instead of silently selecting one', async () => { + const fetcher = responses([ + { email: 'unverified@example.test', primary: true, verified: false }, + { email: 'secondary@example.test', primary: false, verified: true }, + ]) + await expect(fetchVerifiedGitHubProfile('synthetic-token', fetcher)).rejects.toThrow('identity verification failed') + }) + + it('fails closed if GitHub email lookup is unavailable', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(Response.json({ id: 47820304, login: 'PeterGuy326' })) + .mockResolvedValueOnce(new Response(null, { status: 403 })) + await expect(fetchVerifiedGitHubProfile('synthetic-token', fetcher)).rejects.toThrow('identity verification failed') + }) + + it.each([ + { id: 0, login: 'PeterGuy326' }, + { id: 47820304, login: ' ' }, + ])('rejects a malformed stable GitHub identity', async (profile) => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(Response.json(profile)) + .mockResolvedValueOnce(Response.json([{ email: 'verified@example.test', primary: true, verified: true }])) + await expect(fetchVerifiedGitHubProfile('synthetic-token', fetcher)).rejects.toThrow('identity verification failed') + }) +}) diff --git a/src/__tests__/lib/preview-database-init.test.ts b/src/__tests__/lib/preview-database-init.test.ts new file mode 100644 index 0000000..9ada059 --- /dev/null +++ b/src/__tests__/lib/preview-database-init.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest' +import { initializeFreshPreviewDatabase } from '../../../services/collaboration/scripts/initialize-preview-database.mjs' + +function relation(schemaName: string, relationName: string, kind = 'v', extensionName: string | null = null) { + return { + schema_name: schemaName, + relation_name: relationName, + kind, + extension_name: extensionName, + owner_name: 'postgres', + definition: ' SELECT metric_helpers.monitor();', + dependencies: ['function metric_helpers.monitor()'], + } +} + +function dependencies(count: number) { + const client = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn().mockResolvedValue({ rows: count ? [relation('public', 'existing', 'r')] : [] }), + end: vi.fn().mockResolvedValue(undefined), + } + return { + client, + options: { + environment: { + NODE_ENV: 'test' as const, + DOC_FRESH_PREVIEW_DATABASE: '1', + DATABASE_URL: 'postgresql://preview.test/empty', + }, + createClient: vi.fn().mockReturnValue(client), + runCommand: vi.fn().mockReturnValue(0), + }, + } +} + +function managedDependencies() { + const setup = dependencies(0) + const views = [ + relation('public', 'pg_stat_statements', 'v', 'pg_stat_statements'), + relation('public', 'pg_stat_statements_info', 'v', 'pg_stat_statements'), + relation('public', 'pg_stat_kcache', 'v', 'pg_stat_kcache'), + relation('public', 'pg_stat_kcache_detail', 'v', 'pg_stat_kcache'), + ...['index_bloat', 'nearly_exhausted_sequences', 'pg_stat_statements', 'table_bloat'].map((name) => + relation('metric_helpers', name) + ), + ] + setup.client.query.mockResolvedValue({ rows: views }) + const baseline = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn().mockResolvedValue({ rows: structuredClone(views) }), + end: vi.fn().mockResolvedValue(undefined), + } + setup.options.createClient.mockReset().mockReturnValueOnce(setup.client).mockReturnValueOnce(baseline) + return { + ...setup, + baseline, + views, + options: { + ...setup.options, + environment: { ...setup.options.environment, DOC_FRESH_PREVIEW_ALLOW_MANAGED_MONITORING: '1' }, + }, + } +} + +describe('fresh preview database initializer', () => { + it('refuses to connect without explicit preview initialization intent', async () => { + const { options } = dependencies(0) + await expect(initializeFreshPreviewDatabase({ ...options, environment: { NODE_ENV: 'test' } })).rejects.toThrow( + 'Explicit fresh-preview' + ) + expect(options.createClient).not.toHaveBeenCalled() + expect(options.runCommand).not.toHaveBeenCalled() + }) + + it('refuses schema mutation when any existing relation is present', async () => { + const { options, client } = dependencies(1) + await expect(initializeFreshPreviewDatabase(options)).rejects.toThrow('empty database') + expect(options.runCommand).not.toHaveBeenCalled() + expect(client.end).toHaveBeenCalled() + }) + + it.each(['template0', 'template1', '%74emplate1', 'postgres'])( + 'rejects empty protected database %s before connecting', + async (name) => { + const { options } = dependencies(0) + options.environment.DATABASE_URL = `postgresql://preview.test/${name}` + await expect(initializeFreshPreviewDatabase(options)).rejects.toThrow('maintenance database') + expect(options.createClient).not.toHaveBeenCalled() + expect(options.runCommand).not.toHaveBeenCalled() + } + ) + + it('runs the guarded repository schema command only after checking an empty database', async () => { + const { options, client } = dependencies(0) + await initializeFreshPreviewDatabase(options) + expect(client.query).toHaveBeenCalled() + expect(client.end).toHaveBeenCalled() + expect(options.runCommand).toHaveBeenCalledOnce() + }) + + it('never reports success when the schema command fails', async () => { + const { options } = dependencies(0) + options.runCommand.mockReturnValue(1) + await expect(initializeFreshPreviewDatabase(options)).rejects.toThrow('schema initialization failed') + }) + + it('allows only confirmed monitoring views identical to template1 with explicit opt-in', async () => { + const { options, client, baseline } = managedDependencies() + await initializeFreshPreviewDatabase(options) + expect(options.createClient).toHaveBeenNthCalledWith(2, 'postgresql://preview.test/template1') + expect(client.query.mock.calls[0][0]).toContain("extension_dependency.refclassid = 'pg_extension'::regclass") + expect(client.query.mock.calls[0][0]).toContain("extension_dependency.deptype = 'e'") + expect(baseline.end).toHaveBeenCalledOnce() + expect(client.end).toHaveBeenCalledOnce() + expect(options.runCommand).toHaveBeenCalledOnce() + }) + + it('retains strict relation rejection without the monitoring opt-in', async () => { + const { options } = managedDependencies() + options.environment.DOC_FRESH_PREVIEW_ALLOW_MANAGED_MONITORING = '0' + await expect(initializeFreshPreviewDatabase(options)).rejects.toThrow('empty database') + expect(options.createClient).toHaveBeenCalledOnce() + expect(options.runCommand).not.toHaveBeenCalled() + }) + + it.each(['r', 'p', 'm', 'f'])( + 'never exempts %s relations even with a monitoring name and extension', + async (kind) => { + const { options, client } = managedDependencies() + client.query.mockResolvedValue({ rows: [relation('public', 'pg_stat_statements', kind, 'pg_stat_statements')] }) + await expect(initializeFreshPreviewDatabase(options)).rejects.toThrow('empty database') + expect(options.runCommand).not.toHaveBeenCalled() + } + ) + + it.each([ + relation('metric_helpers', 'unknown_view'), + relation('public', 'unknown_view', 'v', 'pg_stat_statements'), + relation('public', 'pg_stat_statements'), + relation('public', 'pg_stat_statements', 'v', 'unconfirmed_extension'), + ])('rejects unknown views and incorrect extension membership', async (extra) => { + const { options, client, views } = managedDependencies() + client.query.mockResolvedValue({ rows: [...views, extra] }) + await expect(initializeFreshPreviewDatabase(options)).rejects.toThrow('empty database') + expect(options.runCommand).not.toHaveBeenCalled() + }) + + it.each(['definition', 'owner_name', 'dependencies'])('rejects a changed monitoring %s', async (field) => { + const { options, baseline, views } = managedDependencies() + const changed = structuredClone(views) + Object.assign(changed[4], { [field]: field === 'dependencies' ? ['function user_data.changed()'] : 'changed' }) + baseline.query.mockResolvedValue({ rows: changed }) + await expect(initializeFreshPreviewDatabase(options)).rejects.toThrow('differ from') + expect(options.runCommand).not.toHaveBeenCalled() + expect(baseline.end).toHaveBeenCalledOnce() + }) + + it('rejects missing baseline views and closes both clients', async () => { + const { options, client, baseline } = managedDependencies() + baseline.query.mockResolvedValue({ rows: [] }) + await expect(initializeFreshPreviewDatabase(options)).rejects.toThrow('differ from') + expect(options.runCommand).not.toHaveBeenCalled() + expect(client.end).toHaveBeenCalledOnce() + expect(baseline.end).toHaveBeenCalledOnce() + }) + + it('refuses schema mutation if reading the template database fails', async () => { + const { options, client, baseline } = managedDependencies() + baseline.query.mockRejectedValue(new Error('baseline unavailable')) + await expect(initializeFreshPreviewDatabase(options)).rejects.toThrow('baseline unavailable') + expect(options.runCommand).not.toHaveBeenCalled() + expect(client.end).toHaveBeenCalledOnce() + expect(baseline.end).toHaveBeenCalledOnce() + }) +}) diff --git a/src/app/[locale]/user-info/page.tsx b/src/app/[locale]/user-info/page.tsx index 3aeb858..8344bfe 100644 --- a/src/app/[locale]/user-info/page.tsx +++ b/src/app/[locale]/user-info/page.tsx @@ -2,6 +2,7 @@ import { Link } from '@/i18n/routing' import HomeNav from '@/components/home-nav' import SignOutButton from '@/components/sign-out-button' import PersonalAccessTokenManager from '@/components/personal-access-token-manager' +import GitHubAccountConnection from '@/components/github-account-connection' import { UserProfileForm } from '@/components/user-profile-form' import { getUserInfo } from '@/lib/session' import { getTranslations } from 'next-intl/server' @@ -48,6 +49,7 @@ export default async function UserTestPage({ params }: { params: RouteParams<{ l redirectTo={`/${locale}/work`} /> + diff --git a/src/app/api/account-connections/github/route.ts b/src/app/api/account-connections/github/route.ts new file mode 100644 index 0000000..2f7449b --- /dev/null +++ b/src/app/api/account-connections/github/route.ts @@ -0,0 +1,37 @@ +import { cookies } from 'next/headers' +import { auth } from 'auth' +import { db } from '@/db/db' +import { resolveAuthConfiguration } from '@/lib/auth-configuration' +import { GITHUB_LINK_COOKIE, issueGitHubLinkIntent } from '@/lib/github-account-link' +import { PersonalAccessTokenApiError, requireSameOrigin } from '@/app/api/personal-access-tokens/_shared' + +export async function POST(request: Request) { + try { + requireSameOrigin(request) + const session = await auth() + const userId = session?.user?.id + if (!userId || !(await db.user.findUnique({ where: { id: userId }, select: { id: true } }))) { + return Response.json({ error: { code: 'unauthorized' } }, { status: 401 }) + } + if (!resolveAuthConfiguration().github) { + return Response.json({ error: { code: 'provider_unavailable' } }, { status: 503 }) + } + const cookieStore = await cookies() + const value = await issueGitHubLinkIntent(userId, cookieStore.getAll()) + const publicUrl = process.env.AUTH_URL || process.env.NEXTAUTH_URL || request.url + cookieStore.set(GITHUB_LINK_COOKIE, value, { + httpOnly: true, + secure: new URL(publicUrl).protocol === 'https:', + sameSite: 'lax', + path: '/', + // Keep the intent present for the full OAuth state lifetime; the signed payload expires sooner. + maxAge: 15 * 60, + }) + return Response.json({ data: { ready: true } }, { headers: { 'cache-control': 'no-store' } }) + } catch (error) { + return Response.json( + { error: { code: error instanceof PersonalAccessTokenApiError ? error.code : 'link_unavailable' } }, + { status: error instanceof PersonalAccessTokenApiError ? error.status : 503 } + ) + } +} diff --git a/src/app/api/account-connections/route.ts b/src/app/api/account-connections/route.ts new file mode 100644 index 0000000..1bf4499 --- /dev/null +++ b/src/app/api/account-connections/route.ts @@ -0,0 +1,38 @@ +import { db } from '@/db/db' +import { resolveAuthConfiguration } from '@/lib/auth-configuration' +import { getUserInfo } from '@/lib/session' + +export const dynamic = 'force-dynamic' + +export async function GET() { + const user = await getUserInfo() + if (user?.id == null) { + return Response.json( + { error: { code: 'unauthorized', message: 'Authentication required' } }, + { status: 401, headers: { 'cache-control': 'no-store' } } + ) + } + + try { + const account = await db.account.findFirst({ + where: { userId: user.id, provider: 'github' }, + select: { provider: true }, + }) + return Response.json( + { + data: { + github: { + enabled: Boolean(resolveAuthConfiguration().github), + connected: account != null, + }, + }, + }, + { headers: { 'cache-control': 'no-store' } } + ) + } catch { + return Response.json( + { error: { code: 'unavailable', message: 'Could not load connected accounts' } }, + { status: 503, headers: { 'cache-control': 'no-store' } } + ) + } +} diff --git a/src/auth.ts b/src/auth.ts index defb70b..87f7dbf 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -9,20 +9,37 @@ import Resend from 'next-auth/providers/resend' // 其他 provider 看这里 https://github.com/nextauthjs/next-auth/blob/main/apps/examples/nextjs/auth.ts import type { NextAuthConfig } from 'next-auth' -import { resolveAuthConfiguration } from '@/lib/auth-configuration' +import { isGitHubAccountAllowed, resolveAuthConfiguration } from '@/lib/auth-configuration' +import { cookies } from 'next/headers' +import { authorizeGitHubLink, GITHUB_LINK_COOKIE } from '@/lib/github-account-link' +import { fetchVerifiedGitHubProfile } from '@/lib/github-identity' function genProviders() { const configuration = resolveAuthConfiguration() const providers: NextAuthConfig['providers'] = [] - if (configuration.github) providers.push(GitHub(configuration.github)) + if (configuration.github) { + providers.push( + GitHub({ + ...configuration.github, + checks: ['pkce', 'state'], + account: () => ({}), + userinfo: { + url: 'https://api.github.com/user', + async request({ tokens }: { tokens: { access_token?: string } }) { + return fetchVerifiedGitHubProfile(tokens.access_token) + }, + }, + }) + ) + } if (configuration.smtp) providers.push(Email(configuration.smtp)) if (configuration.resend) providers.push(Resend(configuration.resend)) return providers } -export const config = { +export const config: NextAuthConfig = { trustHost: true, theme: { logo: '/doc-mark.svg', @@ -39,20 +56,33 @@ export const config = { }, secret: process.env.AUTH_SECRET, callbacks: { + async signIn({ account }) { + if (account?.provider !== 'github') return process.env.DOC_PERSONAL_PREVIEW !== '1' + if (!isGitHubAccountAllowed(account.providerAccountId)) return false + try { + const session = await auth() + const cookieStore = await cookies() + const allowed = await authorizeGitHubLink(account.providerAccountId, session?.user?.id, cookieStore.getAll()) + if (cookieStore.has(GITHUB_LINK_COOKIE)) cookieStore.delete(GITHUB_LINK_COOKIE) + return allowed + } catch { + return false + } + }, authorized({ request, auth }) { // const { pathname } = request.nextUrl // if (pathname.startsWith('/work/')) return !!auth // 因为 NextAuth Adapter 默认不支持 middleware,所以这里暂时不用了 return true }, - jwt({ token, trigger, user }) { - if (trigger === 'signIn') { + jwt({ token, user }) { + if (user?.id) { token.id = user.id } return token }, session({ session, token }) { // @ts-ignore - session.user.id = token.id + session.user.id = token.id ?? token.sub return session }, }, diff --git a/src/components/github-account-connection.tsx b/src/components/github-account-connection.tsx new file mode 100644 index 0000000..bbcd44a --- /dev/null +++ b/src/components/github-account-connection.tsx @@ -0,0 +1,72 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Github } from 'lucide-react' +import { signIn } from 'next-auth/react' +import { useLocale, useTranslations } from 'next-intl' +import { Button } from '@/components/ui/button' + +type GitHubConnection = { enabled: boolean; connected: boolean } + +export default function GitHubAccountConnection() { + const t = useTranslations('accountConnections') + const locale = useLocale() + const [connection, setConnection] = useState(null) + const [connecting, setConnecting] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + const controller = new AbortController() + void fetch('/api/account-connections', { cache: 'no-store', signal: controller.signal }) + .then(async (response) => { + if (!response.ok) throw new Error('load_failed') + const body = (await response.json()) as { data: { github: GitHubConnection } } + if (!controller.signal.aborted) setConnection(body.data.github) + }) + .catch(() => { + if (!controller.signal.aborted) setError(t('loadFailed')) + }) + return () => controller.abort() + }, [t]) + + async function connectGitHub() { + setConnecting(true) + setError('') + try { + const response = await fetch('/api/account-connections/github', { method: 'POST' }) + if (!response.ok) throw new Error('link_unavailable') + await signIn('github', { callbackUrl: `/${locale}/user-info` }) + } catch { + setError(t('connectFailed')) + setConnecting(false) + } + } + + return ( +
+

+ + {t('title')} +

+

{t('description')}

+ {connection == null && !error &&

{t('loading')}

} + {connection?.connected &&

{t('connected')}

} + {connection && !connection.connected && ( + <> + {connection.enabled ? ( + + ) : ( +

{t('unavailable')}

+ )} + + )} + {error && ( +

+ {error} +

+ )} +
+ ) +} diff --git a/src/components/user-setting-button.tsx b/src/components/user-setting-button.tsx index feb649d..68e1dda 100644 --- a/src/components/user-setting-button.tsx +++ b/src/components/user-setting-button.tsx @@ -14,6 +14,7 @@ import { UserProfileForm } from './user-profile-form' import { useUserStore } from '@/stores/user-store' import { User } from 'next-auth' import { useTranslations } from 'next-intl' +import GitHubAccountConnection from './github-account-connection' export default function UserSettingButton() { const userInfo = useUserStore((s) => s.userInfo) @@ -34,6 +35,7 @@ export default function UserSettingButton() { {t('changeUerInfo')} + ) diff --git a/src/lib/auth-configuration.ts b/src/lib/auth-configuration.ts index 993c001..62b1fc0 100644 --- a/src/lib/auth-configuration.ts +++ b/src/lib/auth-configuration.ts @@ -89,3 +89,12 @@ export function resolveAuthConfiguration(env: AuthEnvironment = process.env): Re return result } + +/** Optional stable GitHub account IDs for private self-hosted instances. */ +export function isGitHubAccountAllowed(accountId: string | undefined, env: AuthEnvironment = process.env): boolean { + if (env.AUTH_GITHUB_ALLOWED_IDS == null) return env.DOC_PERSONAL_PREVIEW !== '1' + const ids = env.AUTH_GITHUB_ALLOWED_IDS.split(',').map((id) => id.trim()) + // An explicitly empty or malformed allowlist must not turn a private preview public. + if (ids.length === 0 || ids.some((id) => !/^\d+$/.test(id))) return false + return accountId != null && ids.includes(accountId) +} diff --git a/src/lib/github-account-link.ts b/src/lib/github-account-link.ts new file mode 100644 index 0000000..c2a0ed6 --- /dev/null +++ b/src/lib/github-account-link.ts @@ -0,0 +1,93 @@ +import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto' +import { db } from '@/db/db' + +export const GITHUB_LINK_COOKIE = 'doc.github-link-intent' +export const GITHUB_LINK_TTL_SECONDS = 5 * 60 +type Cookie = { name: string; value: string } +type LinkIntent = { nonce: string; userId: string; sessionDigest: string; expiresAt: number } + +function digest(value: string) { + return createHash('sha256').update(value).digest('hex') +} + +function signature(value: string) { + if (!process.env.AUTH_SECRET) throw new Error('Account linking is not configured') + return createHmac('sha256', process.env.AUTH_SECRET).update(value).digest('base64url') +} + +export function authSessionDigest(cookies: Cookie[]) { + const secure = '__Secure-authjs.session-token' + const plain = 'authjs.session-token' + const prefix = cookies.some((cookie) => cookie.name === secure || cookie.name.startsWith(`${secure}.`)) + ? secure + : plain + const chunks = cookies.filter((cookie) => cookie.name === prefix || cookie.name.startsWith(`${prefix}.`)) + if (chunks.length === 0) return null + const whole = chunks.find((cookie) => cookie.name === prefix) + if (whole) return chunks.length === 1 && whole.value ? digest(whole.value) : null + const indexed = chunks.map((cookie) => ({ index: Number(cookie.name.slice(prefix.length + 1)), value: cookie.value })) + indexed.sort((left, right) => left.index - right.index) + if (indexed.some((chunk, index) => !Number.isInteger(chunk.index) || chunk.index !== index || !chunk.value)) + return null + return digest(indexed.map((chunk) => chunk.value).join('')) +} + +export async function issueGitHubLinkIntent(userId: string, cookies: Cookie[]) { + const sessionDigest = authSessionDigest(cookies) + if (!sessionDigest) throw new Error('Account linking requires an authenticated session') + const intent: LinkIntent = { + nonce: randomUUID(), + userId, + sessionDigest, + expiresAt: Date.now() + GITHUB_LINK_TTL_SECONDS * 1000, + } + const payload = Buffer.from(JSON.stringify(intent)).toString('base64url') + const signed = `${payload}.${signature(payload)}` + await db.verificationToken.create({ + data: { identifier: `github-link:${userId}`, token: digest(intent.nonce), expires: new Date(intent.expiresAt) }, + }) + return signed +} + +export async function consumeGitHubLinkIntent(value: string, userId: string | undefined, cookies: Cookie[]) { + try { + if (!userId || value.length > 2048) return false + const [payload, providedSignature, extra] = value.split('.') + if (!payload || !providedSignature || extra != null) return false + const expected = Buffer.from(signature(payload)) + const provided = Buffer.from(providedSignature) + if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) return false + const intent = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as LinkIntent + if ( + typeof intent.nonce !== 'string' || + intent.userId !== userId || + intent.sessionDigest !== authSessionDigest(cookies) || + !Number.isFinite(intent.expiresAt) || + intent.expiresAt <= Date.now() + ) + return false + const consumed = await db.verificationToken.deleteMany({ + where: { + identifier: `github-link:${userId}`, + token: digest(intent.nonce), + expires: { gt: new Date() }, + }, + }) + return consumed.count === 1 + } catch { + return false + } +} + +export async function authorizeGitHubLink(accountId: string, userId: string | undefined, cookies: Cookie[]) { + if (userId && !(await db.user.findUnique({ where: { id: userId }, select: { id: true } }))) return false + const intent = cookies.find((cookie) => cookie.name === GITHUB_LINK_COOKIE) + if (intent) return consumeGitHubLinkIntent(intent.value, userId, cookies) + if (!userId) return true // Ordinary anonymous login; Auth.js checks existing account/email ownership. + const account = await db.account.findUnique({ + where: { provider_providerAccountId: { provider: 'github', providerAccountId: accountId } }, + select: { userId: true }, + }) + // A signed-in user needs an explicit actor/session-bound intent to attach a new subject. + return account?.userId === userId +} diff --git a/src/lib/github-identity.ts b/src/lib/github-identity.ts new file mode 100644 index 0000000..f6bd082 --- /dev/null +++ b/src/lib/github-identity.ts @@ -0,0 +1,35 @@ +/** Use GitHub's verified primary email, never an unverified public profile field. */ +export async function fetchVerifiedGitHubProfile(accessToken: string | undefined, fetcher: typeof fetch = fetch) { + if (!accessToken) throw new Error('GitHub identity verification failed') + const headers = { + authorization: `Bearer ${accessToken}`, + accept: 'application/vnd.github+json', + 'user-agent': 'doc', + } + const options: RequestInit = { headers, redirect: 'error', signal: AbortSignal.timeout(15_000) } + const [profileResponse, emailsResponse] = await Promise.all([ + fetcher('https://api.github.com/user', options), + fetcher('https://api.github.com/user/emails', options), + ]) + if (!profileResponse.ok || !emailsResponse.ok) throw new Error('GitHub identity verification failed') + const profile = (await profileResponse.json()) as Record + const emails = (await emailsResponse.json()) as Array<{ email?: unknown; primary?: unknown; verified?: unknown }> + if ( + !Number.isSafeInteger(profile.id) || + (profile.id as number) <= 0 || + typeof profile.login !== 'string' || + !profile.login.trim() || + !Array.isArray(emails) + ) { + throw new Error('GitHub identity verification failed') + } + const primary = emails.find( + (email) => + email.primary === true && + email.verified === true && + typeof email.email === 'string' && + /^[^\s@]+@[^\s@]+$/.test(email.email) + ) + if (!primary) throw new Error('GitHub identity verification failed') + return { ...profile, email: primary.email as string } +} diff --git a/src/lib/session.ts b/src/lib/session.ts index 0ccff58..62a3128 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -13,13 +13,13 @@ export async function getUserInfo() { } const user = session.user // 格式如 { id, name, email, image } - if (user.email == null) { + if (user.id == null) { return null } try { const persistedUser = await db.user.findUnique({ - where: { email: user.email }, + where: { id: user.id }, select: { id: true, email: true, name: true, image: true }, }) if (persistedUser == null) {