Skip to content

Monorepo restructure: add DS app, migrate web to workspaces, and integrate signed DS storage for backups/shares#212

Open
EvanTechDev wants to merge 2 commits intodevfrom
feature/upgrade-project-to-monorepo-with-turborepo-o38u1i
Open

Monorepo restructure: add DS app, migrate web to workspaces, and integrate signed DS storage for backups/shares#212
EvanTechDev wants to merge 2 commits intodevfrom
feature/upgrade-project-to-monorepo-with-turborepo-o38u1i

Conversation

@EvanTechDev
Copy link
Copy Markdown
Owner

@EvanTechDev EvanTechDev commented Mar 28, 2026

Motivation

  • Convert the project into a monorepo with separate apps/web and apps/ds workspaces to enable a dedicated decentralized storage (DS) server and shared packages.
  • Introduce a signed DS protocol so ATProto-authenticated users can store and migrate encrypted backups and shares to a user-configured DS endpoint.
  • Move UI and i18n code into reusable packages to centralize shared components and locale generation.

Description

  • Add a new apps/ds Next.js app implementing DS REST endpoints and DB access including api/blob, api/share, api/migrate/{export,import,cleanup}, table creation (ensureTables) and a signature verification layer in lib/signature.ts that validates signed requests from ATProto sessions.
  • Add apps/web workspace and refactor the original web app into apps/web/*, adding DS integration: api/ds/config, api/ds/migrate, api/ds/proxy, lib/ds-signed-request.ts, lib/ds-client.ts, and changes to blob/share APIs to prefer DS when an ATProto DS is configured.
  • Move UI components into packages/ui and i18n into packages/i18n, add packages/config for shared config, update import paths and component references, and add packages/ui/src/utils.ts helper.
  • Update repository config to a Turborepo layout: add turbo.json, change package.json to workspaces and monorepo scripts, add apps/* and packages/* manifests, update README.md for monorepo usage, and adjust .gitignore and other build configs.

Testing

  • Performed smoke builds of the new workspaces by running the workspace build flow and locale generation (e.g. the repo uses Turbo/Bun build commands as described in README.md) to validate project layout and generators completed without file-path errors.
  • No dedicated automated test suites were added in this changeset; existing runtime behavior for ATProto/DS flows should be validated in integration environments with configured DS_APP_TOKEN, POSTGRES_URL, and ATProto session secrets.

Codex Task

Summary by Sourcery

将项目转换为 Turborepo 单体仓库(monorepo)结构,拆分为独立的 web 应用和去中心化存储(DS)应用,并将 web 应用接入使用签名 DS 后端的 ATProto 用户备份和分享功能。

New Features:

  • 引入独立的 DS Next.js 应用,提供由 PostgreSQL 支持的 API,用于加密日历备份、分享数据存储以及迁移操作,并通过已签名的 ATProto 会话进行安全防护。
  • 在 web 应用中新增 DS 配置、代理和迁移相关的 API 以及 UI,使通过 ATProto 认证的用户可以设置自定义 DS 端点,并在不同 DS 实例之间迁移他们的数据。
  • 暴露一个基于 DID 的分享查看路由,用于解析创作者的 DS 端点并从 DS 服务器获取共享数据。

Enhancements:

  • 将通用 UI 组件重构为可复用的 @repo/ui 包,并将 i18n 的生成与运行时逻辑集中到 @repo/i18n 包中。
  • 更新 ATProto 鉴权流程和会话处理方式,使其由来源(origin)驱动而非基于环境变量(env),并支持 DS 签名密钥。
  • 调整 toast 行为,从 localStorage 读取位置设置,而不再依赖应用特定的 hooks。
  • 通过共享的 @repo/config 包统一 TS/Next/tailwind/postcss 配置,并相应地对各应用的 TS 配置进行对齐。

Build:

  • 将代码仓库切换为 Turborepo 工作区布局,使用 apps/*packages/* 工作区结构,并在根目录的 package.json 中使用 Turbo 驱动开发和构建脚本。

Documentation:

  • 更新 README 以适配新的 monorepo 结构、命令以及按工作区划分的环境配置。
Original summary in English

Summary by Sourcery

Convert the project to a Turborepo monorepo with separate web and decentralized storage (DS) apps and wire the web app to use a signed DS backend for ATProto user backups and shares.

New Features:

  • Introduce a dedicated DS Next.js app with PostgreSQL-backed APIs for encrypted calendar backups, share storage, and migration operations secured by signed ATProto sessions.
  • Add DS configuration, proxy, and migration APIs plus UI in the web app so ATProto-authenticated users can set a custom DS endpoint and move their data between DS instances.
  • Expose a DID-based share viewing route that resolves a creator's DS endpoint and fetches shared data from the DS server.

Enhancements:

  • Refactor shared UI components into a reusable @repo/ui package and centralize i18n generation and runtime into a @repo/i18n package.
  • Update ATProto auth flows and session handling to be origin-driven instead of env-based and to support DS signing keys.
  • Adjust toast behavior to read position from localStorage without depending on app-specific hooks.
  • Standardize TS/Next/tailwind/postcss configuration via a shared @repo/config package and align app-level TS configs accordingly.

Build:

  • Switch the repository to a Turborepo workspace layout with apps/* and packages/* workspaces and Turbo-driven dev/build scripts in the root package.json.

Documentation:

  • Update the README for the new monorepo structure, commands, and workspace-specific environment configuration.

@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Mar 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
one-calendar Ready Ready Preview, Comment, Open in v0 Mar 28, 2026 0:02am

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai bot commented Mar 28, 2026

审阅者指南

将项目重构为一个由 Turbo 驱动的 monorepo,拆分为独立的 web 和 DS 应用;引入由专用的基于 Next.js + PostgreSQL 的 DS 服务器支撑的「已签名去中心化存储(DS)协议」;重构 ATProto 流程以优先使用 DS 进行备份和分享;并将共用的 UI/i18n/config 抽取到可复用的包中。

通过 web /api/blob 写入已签名 DS blob 的时序图

sequenceDiagram
  actor User
  participant Browser
  participant WebApi as Web_app_api_blob
  participant AtprotoLib as Atproto_session_and_record
  participant DsSigned as ds_signed_request
  participant DsApi as DS_api_blob
  participant Signature as DS_signature_verifier
  participant DB as DS_PostgreSQL

  User->>Browser: Save encrypted backup
  Browser->>WebApi: POST /api/blob

  WebApi->>AtprotoLib: getAtprotoSession()
  alt ATProto session exists
    WebApi->>AtprotoLib: getRecord(app.onecalendar.ds,self)
    AtprotoLib-->>WebApi: ds endpoint URL
    alt ds endpoint is configured
      WebApi->>DsSigned: signedDsFetch(session, ds, "/api/blob", POST, {encrypted_data, iv, timestamp})
      DsSigned->>DsSigned: createPayload(method,path,timestamp,body)
      DsSigned->>DsSigned: signPayload(payload, dpopPrivateKeyPem)
      DsSigned->>DsApi: POST /api/blob
      Note over DsSigned,DsApi: Sends x-app-token, x-did, x-timestamp,<br/>x-signature, x-dpop-jwk

      DsApi->>Signature: requireSignedRequest(request, rawBody)
      Signature->>Signature: validate x-app-token
      Signature->>Signature: rebuild payload and verify signature
      Signature-->>DsApi: did (on success)

      DsApi->>DB: upsert calendar_backups by user_id=did
      DB-->>DsApi: ok
      DsApi-->>DsSigned: 200 {success:true}
      DsSigned-->>WebApi: 200 OK
      WebApi-->>Browser: 200 {success:true, backend: "ds", ds}
    else ds endpoint missing
      WebApi-->>Browser: 400 {error:"ATProto DS is not configured"}
    end
  else no ATProto session
    WebApi->>DB: use legacy local backup flow
    DB-->>WebApi: ok
    WebApi-->>Browser: 200 {success:true, backend:"db"}
  end
Loading

通过 /api/ds/migrate 和 DS migrate 接口进行 DS 迁移的时序图

sequenceDiagram
  actor User
  participant Browser
  participant WebMigrate as Web_api_ds_migrate
  participant Atproto as Atproto_session_and_record
  participant DsSigned as ds_signed_request
  participant DsFrom as Source_DS_server
  participant DsTo as Target_DS_server

  User->>Browser: Click "migrate" with target DS URL
  Browser->>WebMigrate: POST /api/ds/migrate {toDs}

  WebMigrate->>Atproto: getAtprotoSession()
  alt no ATProto session
    WebMigrate-->>Browser: 401 {error:"ATProto login required"}
  else ATProto session exists
    WebMigrate->>Atproto: getRecord(app.onecalendar.ds,self)
    Atproto-->>WebMigrate: current ds (fromDs)
    alt fromDs missing
      WebMigrate-->>Browser: 400 {error:"No source DS record found"}
    else fromDs present and != toDs
      Note over WebMigrate,DsFrom: Export from source DS
      WebMigrate->>DsSigned: signedDsFetch(session, fromDs, "/api/migrate/export", POST, {did})
      DsSigned->>DsFrom: POST /api/migrate/export (signed)
      DsFrom-->>DsSigned: 200 {backups, shares}
      DsSigned-->>WebMigrate: payload

      Note over WebMigrate,DsTo: Import into target DS
      WebMigrate->>DsSigned: signedDsFetch(session, toDs, "/api/migrate/import", POST, payload)
      DsSigned->>DsTo: POST /api/migrate/import (signed)
      DsTo-->>DsSigned: 200 {success:true}
      DsSigned-->>WebMigrate: ok

      Note over WebMigrate,DsFrom: Cleanup on source DS
      WebMigrate->>DsSigned: signedDsFetch(session, fromDs, "/api/migrate/cleanup", POST, {did})
      DsSigned->>DsFrom: POST /api/migrate/cleanup (signed)
      DsFrom-->>DsSigned: 200 {success:true}
      DsSigned-->>WebMigrate: ok

      WebMigrate->>Atproto: putRecord(app.onecalendar.ds,self,{ds:toDs,updatedAt})
      Atproto-->>WebMigrate: ok
      WebMigrate-->>Browser: 200 {success:true, fromDs, toDs, switched:true}
    end
  end
Loading

DS PostgreSQL 表的实体关系图

erDiagram
  calendar_backups {
    SERIAL id
    TEXT user_id
    TEXT encrypted_data
    TEXT iv
    BIGINT timestamp
    TIMESTAMPTZ updated_at
  }

  shares {
    SERIAL id
    TEXT user_id
    TEXT share_id
    TEXT data
    BIGINT timestamp
    TIMESTAMPTZ created_at
  }

  users {
    TEXT did
  }

  users ||--o{ calendar_backups : has_backup
  users ||--o{ shares : has_share

  shares {
    %% logical indexes
  }

  calendar_backups {
    %% logical index on user_id (unique per user)
  }
Loading

文件级变更

变更 详情 文件
引入专用 DS 服务器应用,用于存储加密备份和分享数据,并支持已签名的迁移流程。
  • 在 apps/ds 下创建 Next.js DS 应用,包含最简布局和健康检查页面
  • 添加 PostgreSQL 连接池和 ensureTables helper,用于管理 calendar_backupsshares
  • lib/signature.ts 中实现签名请求校验逻辑,支持使用 DPoP JWK 或 DID plc 密钥,并进行时间戳偏移检查及应用 token 校验
  • 暴露用于 blob CRUD、share CRUD、公开分享读取以及迁移导出/导入/清理的 REST 接口,并在需要时通过已签名请求和 DS_APP_TOKEN 进行保护
  • 添加 DS 专用的 tsconfig 和 Next.js 配置,以及 pg 类型声明 shim
apps/ds/app/page.tsx
apps/ds/app/layout.tsx
apps/ds/app/api/blob/route.ts
apps/ds/app/api/share/route.ts
apps/ds/app/api/share/[shareId]/route.ts
apps/ds/app/api/migrate/export/route.ts
apps/ds/app/api/migrate/import/route.ts
apps/ds/app/api/migrate/cleanup/route.ts
apps/ds/lib/db.ts
apps/ds/lib/signature.ts
apps/ds/next.config.ts
apps/ds/package.json
apps/ds/tsconfig.json
apps/ds/types/pg.d.ts
将 web 应用重构到 apps/web 工作区,并通过已签名请求与 DS 以及 ATProto DS 记录进行联动。
  • 将原有 Next.js 应用迁移至 apps/web(页面、API 路由、样式、OAuth 流程),并新增工作区本地的 package.json、tsconfig、postcss 和 tailwind 配置,这些配置继承自共享配置
  • 在 ATProto 登录/回调/注册路由中,将基于环境变量的 base URL 逻辑替换为 request.origin,并增强来源校验及安全 Cookie 处理
  • 添加已签名的 DS 客户端 helper(ds-signed-request.tsds-client.ts),使用 ATProto DPoP 私钥为 payload 签名,附带 x-did/x-timestamp/x-signature/x-dpop-jwk/x-app-token 头,并通过代理 API 路由与 DS 通信
  • 引入 /api/ds/config/api/ds/migrate API 路由,用于读写用户 PDS 上的 app.onecalendar.ds 记录,并通过 DS 的 migrate export/import/cleanup 接口编排 DS 到 DS 的迁移
  • 添加 /api/ds/proxy 端点,通过当前 ATProto 会话,将来自浏览器的任意已签名 DS 请求转发到 DS
  • 更新 app/api/blobapp/api/share(以及公开分享路由),从 app.onecalendar.ds 中解析用户的 DS endpoint,对 blob/share 的 CRUD 优先使用 DS,在必要时回退至 PDS 或本地 DB,并暴露详细的 DS 错误和 HTTP 状态码
  • 新增基于 DID 的分享路由 app/(app)/share/[did]/[shareId],通过某个 DID 的 app.onecalendar.ds 记录解析 DS,并使用 DS_APP_TOKEN 直接从 DS 拉取分享数据
apps/web/app/api/blob/route.ts
apps/web/app/api/share/route.ts
apps/web/app/api/share/public/route.ts
apps/web/app/api/ds/config/route.ts
apps/web/app/api/ds/migrate/route.ts
apps/web/app/api/ds/proxy/route.ts
apps/web/app/api/atproto/login/route.ts
apps/web/app/api/atproto/callback/route.ts
apps/web/app/api/atproto/logout/route.ts
apps/web/app/api/atproto/register-url/route.ts
apps/web/app/api/atproto/session/route.ts
apps/web/app/oauth-client-metadata.json/route.ts
apps/web/app/(app)/app/page.tsx
apps/web/app/(app)/share/[did]/[shareId]/page.tsx
apps/web/app/(auth)/at-oauth/page.tsx
apps/web/app/globals.css
apps/web/lib/ds-signed-request.ts
apps/web/lib/ds-client.ts
apps/web/lib/atproto-auth.ts
apps/web/lib/atproto-feature.ts
apps/web/postcss.config.mjs
apps/web/tailwind.config.ts
apps/web/tsconfig.json
apps/web/package.json
在 web UI 中暴露 DS 配置和迁移控制,并调整会话处理/加载流程。
  • 扩展主应用页面,在 Clerk 和 DB/DS 就绪检查通过前阻止渲染;在展示日历前,预先探测 ATProto 会话、DS 配置以及 blob 可用性
  • 为已使用 ATProto 登录但缺少 app.onecalendar.ds 的用户添加 DS 配置对话框;对话框通过 /api/ds/config 写入 DS 配置并在成功后刷新页面
  • 增强设置页:增加 ATProto DS 地址输入框、DS 迁移目标地址输入框、状态提示文案,并与 /api/ds/config/api/ds/migrate 接口打通
  • 确保组件中的 ATProto 会话检查(事件预览、用户头像按钮等)对 /api/atproto/session 禁用缓存,以保持 DS/ATProto 状态的实时性
  • 调整 atproto/session 端点,使其在需要时通过 PDS actor profile 解析头像
apps/web/app/(app)/app/page.tsx
apps/web/components/app/profile/settings.tsx
apps/web/components/app/event/event-preview.tsx
apps/web/components/app/profile/user-profile-button.tsx
apps/web/app/api/atproto/session/route.ts
将共享的 UI 组件、i18n 和配置抽取为可复用的包,并更新引用。
  • 创建 @repo/ui 包,提供 shadcn 风格的基础组件(button、dialog、toast、表单控件、sonner Toaster、工具方法 cn),并调整实现以使用本地的 ./utils 及同目录组件,而不是应用级导入
  • 创建 @repo/i18n 包,包含 src/i18n.tsgen-locales 脚本;脚本从 JSON 文件生成 src/locales.ts,并相应调整生成器路径和注释
  • 创建 @repo/config 包,导出共享的 postcss、tailwind 和 tsconfig.base.json;更新 apps/web 和 apps/ds 以扩展这些配置,并通过 re-export 的方式接入 tailwind/postcss
  • 更新 web 的 tsconfig 路径别名,指向共享的 UI 和 i18n 模块;更新 i18n 导入路径以使用新的 locales 模块,并添加 Tailwind 的 @source 指令,让应用能够解析 UI 包中的组件
  • 更新各个 UI 组件文件,从 ./utils 或 UI 包内部其他本地文件中导入 cnButtonToast 类型等;同时调整 lucide-react 图标用法(例如 GithubIcon -> Github)以匹配新的依赖版本
packages/ui/package.json
packages/ui/src/utils.ts
packages/ui/src/accordion.tsx
packages/ui/src/alert.tsx
packages/ui/src/alert-dialog.tsx
packages/ui/src/avatar.tsx
packages/ui/src/badge.tsx
packages/ui/src/button.tsx
packages/ui/src/calendar.tsx
packages/ui/src/card.tsx
packages/ui/src/checkbox.tsx
packages/ui/src/context-menu.tsx
packages/ui/src/dialog.tsx
packages/ui/src/dropdown-menu.tsx
packages/ui/src/empty.tsx
packages/ui/src/input.tsx
packages/ui/src/kbd.tsx
packages/ui/src/label.tsx
packages/ui/src/popover.tsx
packages/ui/src/scroll-area.tsx
packages/ui/src/select.tsx
packages/ui/src/separator.tsx
packages/ui/src/sheet.tsx
packages/ui/src/spinner.tsx
packages/ui/src/switch.tsx
packages/ui/src/tabs.tsx
packages/ui/src/textarea.tsx
packages/ui/src/toast.tsx
packages/ui/src/toaster.tsx
packages/ui/src/use-toast.tsx
packages/ui/src/sonner.tsx
packages/i18n/package.json
packages/i18n/src/i18n.ts
packages/i18n/scripts/gen-locales.mjs
packages/config/package.json
packages/config/tsconfig.base.json
packages/config/postcss.config.mjs
packages/config/tailwind.config.ts
apps/web/app/globals.css
apps/web/lib/i18n.ts
apps/web/app/(app)/about/page.tsx
将仓库转换为基于 Turbo 的 monorepo,并文档化新的目录结构与工作流。
  • 用 monorepo 的根 package.json 替换原有文件,声明 apps/*packages/* 工作区,并提供基于 Turbo 的 dev/build/start 脚本以及 DS 专用命令
  • 添加 turbo.json,定义 web、DS 和共享包的构建管线(未在 diff 中展示,但已创建)
  • 移除旧的单应用配置文件(根目录的 tailwind.config.ts、旧的应用入口以及 .env.example),这些已由应用级和包级配置取代
  • 更新 README,描述新的 monorepo 结构,更新横幅资源路径到 apps/web/public,更新入门命令,并将环境配置文件位置指向 apps/web/.env
  • 根据新的目录结构,更新 .gitignore 和其他根级构建/配置文件
package.json
turbo.json
README.md
.gitignore
apps/web/app/(app)/app/page.tsx (removed old root version)
apps/web/app/(auth)/at-oauth/page.tsx (removed old root version)
apps/web/app/api/atproto/logout/route.ts (replaced commented stub)
apps/web/app/api/atproto/login/route.ts (replaced commented stub)
apps/web/app/api/atproto/register-url/route.ts (replaced commented stub)
apps/web/lib/atproto-feature.ts (replaces root lib version)
apps/web/app/globals.css
apps/web/postcss.config.mjs
apps/web/tailwind.config.ts
apps/web/tsconfig.json
tailwind.config.ts (deleted)
.env.example (deleted)

提示与命令

与 Sourcery 交互

  • 触发新的评审: 在 Pull Request 中评论 @sourcery-ai review
  • 继续讨论: 直接回复 Sourcery 的评审评论。
  • 从评审评论生成 GitHub issue: 在某条评审评论下回复,要求 Sourcery 从该评论创建 issue。你也可以直接在该评论下回复 @sourcery-ai issue 来创建 issue。
  • 生成 Pull Request 标题: 在 Pull Request 标题任意位置写入 @sourcery-ai 即可随时生成标题。你也可以在 Pull Request 中评论 @sourcery-ai title 来(重新)生成标题。
  • 生成 Pull Request 摘要: 在 Pull Request 描述中任意位置写入 @sourcery-ai summary,即可在对应位置生成 PR 摘要。你也可以在 Pull Request 中评论 @sourcery-ai summary 来(重新)生成摘要。
  • 生成审阅者指南: 在 Pull Request 中评论 @sourcery-ai guide,即可(重新)生成审阅者指南。
  • 一次性解决所有 Sourcery 评论: 在 Pull Request 中评论 @sourcery-ai resolve,将所有 Sourcery 评论标记为已解决。如果你已经处理完所有评论且不想再看到它们,这会很有用。
  • 忽略所有 Sourcery 评审: 在 Pull Request 中评论 @sourcery-ai dismiss,即可忽略所有现有的 Sourcery 评审。若希望从一次全新的评审开始,这尤其有用 —— 记得随后评论 @sourcery-ai review 以触发新评审!

自定义使用体验

访问你的 控制面板 来:

  • 启用或停用评审功能,例如 Sourcery 自动生成的 Pull Request 摘要、审阅者指南等。
  • 修改评审语言。
  • 添加、移除或编辑自定义评审说明。
  • 调整其他评审相关设置。

获取帮助

Original review guide in English

Reviewer's Guide

Restructures the project into a Turbo-powered monorepo with separate web and DS apps, introduces a signed decentralized storage (DS) protocol backed by a dedicated Next.js DS server with PostgreSQL, refactors ATProto flows to prefer DS for backups and shares, and extracts shared UI/i18n/config into reusable packages.

Sequence diagram for signed DS blob write via web /api/blob

sequenceDiagram
  actor User
  participant Browser
  participant WebApi as Web_app_api_blob
  participant AtprotoLib as Atproto_session_and_record
  participant DsSigned as ds_signed_request
  participant DsApi as DS_api_blob
  participant Signature as DS_signature_verifier
  participant DB as DS_PostgreSQL

  User->>Browser: Save encrypted backup
  Browser->>WebApi: POST /api/blob

  WebApi->>AtprotoLib: getAtprotoSession()
  alt ATProto session exists
    WebApi->>AtprotoLib: getRecord(app.onecalendar.ds,self)
    AtprotoLib-->>WebApi: ds endpoint URL
    alt ds endpoint is configured
      WebApi->>DsSigned: signedDsFetch(session, ds, "/api/blob", POST, {encrypted_data, iv, timestamp})
      DsSigned->>DsSigned: createPayload(method,path,timestamp,body)
      DsSigned->>DsSigned: signPayload(payload, dpopPrivateKeyPem)
      DsSigned->>DsApi: POST /api/blob
      Note over DsSigned,DsApi: Sends x-app-token, x-did, x-timestamp,<br/>x-signature, x-dpop-jwk

      DsApi->>Signature: requireSignedRequest(request, rawBody)
      Signature->>Signature: validate x-app-token
      Signature->>Signature: rebuild payload and verify signature
      Signature-->>DsApi: did (on success)

      DsApi->>DB: upsert calendar_backups by user_id=did
      DB-->>DsApi: ok
      DsApi-->>DsSigned: 200 {success:true}
      DsSigned-->>WebApi: 200 OK
      WebApi-->>Browser: 200 {success:true, backend: "ds", ds}
    else ds endpoint missing
      WebApi-->>Browser: 400 {error:"ATProto DS is not configured"}
    end
  else no ATProto session
    WebApi->>DB: use legacy local backup flow
    DB-->>WebApi: ok
    WebApi-->>Browser: 200 {success:true, backend:"db"}
  end
Loading

Sequence diagram for DS migration via /api/ds/migrate and DS migrate endpoints

sequenceDiagram
  actor User
  participant Browser
  participant WebMigrate as Web_api_ds_migrate
  participant Atproto as Atproto_session_and_record
  participant DsSigned as ds_signed_request
  participant DsFrom as Source_DS_server
  participant DsTo as Target_DS_server

  User->>Browser: Click "migrate" with target DS URL
  Browser->>WebMigrate: POST /api/ds/migrate {toDs}

  WebMigrate->>Atproto: getAtprotoSession()
  alt no ATProto session
    WebMigrate-->>Browser: 401 {error:"ATProto login required"}
  else ATProto session exists
    WebMigrate->>Atproto: getRecord(app.onecalendar.ds,self)
    Atproto-->>WebMigrate: current ds (fromDs)
    alt fromDs missing
      WebMigrate-->>Browser: 400 {error:"No source DS record found"}
    else fromDs present and != toDs
      Note over WebMigrate,DsFrom: Export from source DS
      WebMigrate->>DsSigned: signedDsFetch(session, fromDs, "/api/migrate/export", POST, {did})
      DsSigned->>DsFrom: POST /api/migrate/export (signed)
      DsFrom-->>DsSigned: 200 {backups, shares}
      DsSigned-->>WebMigrate: payload

      Note over WebMigrate,DsTo: Import into target DS
      WebMigrate->>DsSigned: signedDsFetch(session, toDs, "/api/migrate/import", POST, payload)
      DsSigned->>DsTo: POST /api/migrate/import (signed)
      DsTo-->>DsSigned: 200 {success:true}
      DsSigned-->>WebMigrate: ok

      Note over WebMigrate,DsFrom: Cleanup on source DS
      WebMigrate->>DsSigned: signedDsFetch(session, fromDs, "/api/migrate/cleanup", POST, {did})
      DsSigned->>DsFrom: POST /api/migrate/cleanup (signed)
      DsFrom-->>DsSigned: 200 {success:true}
      DsSigned-->>WebMigrate: ok

      WebMigrate->>Atproto: putRecord(app.onecalendar.ds,self,{ds:toDs,updatedAt})
      Atproto-->>WebMigrate: ok
      WebMigrate-->>Browser: 200 {success:true, fromDs, toDs, switched:true}
    end
  end
Loading

Entity relationship diagram for DS PostgreSQL tables

erDiagram
  calendar_backups {
    SERIAL id
    TEXT user_id
    TEXT encrypted_data
    TEXT iv
    BIGINT timestamp
    TIMESTAMPTZ updated_at
  }

  shares {
    SERIAL id
    TEXT user_id
    TEXT share_id
    TEXT data
    BIGINT timestamp
    TIMESTAMPTZ created_at
  }

  users {
    TEXT did
  }

  users ||--o{ calendar_backups : has_backup
  users ||--o{ shares : has_share

  shares {
    %% logical indexes
  }

  calendar_backups {
    %% logical index on user_id (unique per user)
  }
Loading

File-Level Changes

Change Details Files
Introduce dedicated DS server app that stores encrypted backups and shares and supports signed migration flows.
  • Create Next.js DS app under apps/ds with minimal layout and health page
  • Add PostgreSQL pool and ensureTables helper to manage calendar_backups and shares tables
  • Implement signed request verification in lib/signature.ts using DPoP JWK or DID plc key with timestamp skew checks and app token
  • Expose REST endpoints for blob CRUD, share CRUD, public share read, and migration export/import/cleanup, all guarded by signed requests and DS_APP_TOKEN where appropriate
  • Add DS-specific tsconfig and Next.js configuration plus pg type declaration shim
apps/ds/app/page.tsx
apps/ds/app/layout.tsx
apps/ds/app/api/blob/route.ts
apps/ds/app/api/share/route.ts
apps/ds/app/api/share/[shareId]/route.ts
apps/ds/app/api/migrate/export/route.ts
apps/ds/app/api/migrate/import/route.ts
apps/ds/app/api/migrate/cleanup/route.ts
apps/ds/lib/db.ts
apps/ds/lib/signature.ts
apps/ds/next.config.ts
apps/ds/package.json
apps/ds/tsconfig.json
apps/ds/types/pg.d.ts
Refactor web app into apps/web workspace and wire it to DS via signed requests and ATProto DS records.
  • Move original Next.js app into apps/web (pages, API routes, styles, OAuth flows) and add workspace-local package.json, tsconfig, postcss, and tailwind config that extend shared config
  • Replace environment-based base URL logic in ATProto login/callback/register routes with request.origin and harden origin validation/secure cookie handling
  • Add signed DS client helpers (ds-signed-request.ts and ds-client.ts) that sign payloads with the ATProto DPoP private key, include x-did/x-timestamp/x-signature/x-dpop-jwk/x-app-token headers, and talk to DS via a proxy API route
  • Introduce /api/ds/config and /api/ds/migrate API routes to read/write the app.onecalendar.ds record on the user’s PDS and orchestrate DS-to-DS migration using DS migrate export/import/cleanup endpoints
  • Add /api/ds/proxy endpoint to forward arbitrary signed DS requests from the browser through the web server using current ATProto session
  • Update app/api/blob and app/api/share (plus public share route) to resolve the user’s DS endpoint from app.onecalendar.ds, prefer DS for blob/share CRUD, fall back to PDS or local DB where necessary, and surface detailed DS errors and HTTP status codes
  • Add a DID-based share route (app/(app)/share/[did]/[shareId]) that resolves DS from a DID’s app.onecalendar.ds record and fetches share payloads directly from the DS using DS_APP_TOKEN
apps/web/app/api/blob/route.ts
apps/web/app/api/share/route.ts
apps/web/app/api/share/public/route.ts
apps/web/app/api/ds/config/route.ts
apps/web/app/api/ds/migrate/route.ts
apps/web/app/api/ds/proxy/route.ts
apps/web/app/api/atproto/login/route.ts
apps/web/app/api/atproto/callback/route.ts
apps/web/app/api/atproto/logout/route.ts
apps/web/app/api/atproto/register-url/route.ts
apps/web/app/api/atproto/session/route.ts
apps/web/app/oauth-client-metadata.json/route.ts
apps/web/app/(app)/app/page.tsx
apps/web/app/(app)/share/[did]/[shareId]/page.tsx
apps/web/app/(auth)/at-oauth/page.tsx
apps/web/app/globals.css
apps/web/lib/ds-signed-request.ts
apps/web/lib/ds-client.ts
apps/web/lib/atproto-auth.ts
apps/web/lib/atproto-feature.ts
apps/web/postcss.config.mjs
apps/web/tailwind.config.ts
apps/web/tsconfig.json
apps/web/package.json
Expose DS configuration and migration controls in the web UI and adjust session handling/loading flows.
  • Extend main app page to block rendering until Clerk and DB/DS readiness checks pass, probing ATProto session, DS configuration, and blob availability before showing Calendar
  • Add a DS configuration dialog shown to ATProto-signed-in users missing app.onecalendar.ds; dialog writes DS via /api/ds/config and reloads on success
  • Enhance settings page with ATProto DS address input, DS migration target input, status messaging, and wiring to /api/ds/config and /api/ds/migrate
  • Ensure ATProto session checks in components (event preview, user profile button) disable caching for /api/atproto/session so DS/ATProto state stays fresh
  • Adjust atproto/session endpoint to resolve profile avatar via PDS actor profile when needed
apps/web/app/(app)/app/page.tsx
apps/web/components/app/profile/settings.tsx
apps/web/components/app/event/event-preview.tsx
apps/web/components/app/profile/user-profile-button.tsx
apps/web/app/api/atproto/session/route.ts
Extract shared UI components, i18n, and config into reusable packages and update imports.
  • Create @repo/ui package with shadcn-style primitives (button, dialog, toast, form elements, sonner Toaster, util cn) and adjust implementations to use local ./utils and co-located components instead of app-level imports
  • Create @repo/i18n package with src/i18n.ts and a gen-locales script that generates src/locales.ts from JSON files; adjust generator paths and comments accordingly
  • Create @repo/config package exporting shared postcss, tailwind, and tsconfig.base.json; update apps/web and apps/ds to extend these configs and wire tailwind/postcss via re-exports
  • Point web tsconfig path aliases at shared UI and i18n modules, update i18n imports to use new locales path, and add Tailwind @source directive so app can see UI package components
  • Update various UI component files to import cn, Button, Toast types, etc. from ./utils or other local files inside the UI package; adjust lucide-react icon usages (e.g., GithubIcon -> Github) to match new dependency versions
packages/ui/package.json
packages/ui/src/utils.ts
packages/ui/src/accordion.tsx
packages/ui/src/alert.tsx
packages/ui/src/alert-dialog.tsx
packages/ui/src/avatar.tsx
packages/ui/src/badge.tsx
packages/ui/src/button.tsx
packages/ui/src/calendar.tsx
packages/ui/src/card.tsx
packages/ui/src/checkbox.tsx
packages/ui/src/context-menu.tsx
packages/ui/src/dialog.tsx
packages/ui/src/dropdown-menu.tsx
packages/ui/src/empty.tsx
packages/ui/src/input.tsx
packages/ui/src/kbd.tsx
packages/ui/src/label.tsx
packages/ui/src/popover.tsx
packages/ui/src/scroll-area.tsx
packages/ui/src/select.tsx
packages/ui/src/separator.tsx
packages/ui/src/sheet.tsx
packages/ui/src/spinner.tsx
packages/ui/src/switch.tsx
packages/ui/src/tabs.tsx
packages/ui/src/textarea.tsx
packages/ui/src/toast.tsx
packages/ui/src/toaster.tsx
packages/ui/src/use-toast.tsx
packages/ui/src/sonner.tsx
packages/i18n/package.json
packages/i18n/src/i18n.ts
packages/i18n/scripts/gen-locales.mjs
packages/config/package.json
packages/config/tsconfig.base.json
packages/config/postcss.config.mjs
packages/config/tailwind.config.ts
apps/web/app/globals.css
apps/web/lib/i18n.ts
apps/web/app/(app)/about/page.tsx
Convert repository to a Turbo-based monorepo and document the new layout and workflows.
  • Replace root package.json with a monorepo manifest that declares apps/* and packages/* workspaces and turbo-based dev/build/start scripts, plus DS-specific commands
  • Add turbo.json to define pipelines for web, DS, and shared packages (not shown in diff but created)
  • Remove legacy single-app config files (root tailwind.config.ts, old app entrypoints, and .env.example) now superseded by app-specific and package configs
  • Adjust README to describe monorepo structure, update banner asset paths to apps/web/public, update getting started commands, and point env setup to apps/web/.env
  • Update .gitignore and other root-level build/config files as needed for the new layout
package.json
turbo.json
README.md
.gitignore
apps/web/app/(app)/app/page.tsx (removed old root version)
apps/web/app/(auth)/at-oauth/page.tsx (removed old root version)
apps/web/app/api/atproto/logout/route.ts (replaced commented stub)
apps/web/app/api/atproto/login/route.ts (replaced commented stub)
apps/web/app/api/atproto/register-url/route.ts (replaced commented stub)
apps/web/lib/atproto-feature.ts (replaces root lib version)
apps/web/app/globals.css
apps/web/postcss.config.mjs
apps/web/tailwind.config.ts
apps/web/tsconfig.json
tailwind.config.ts (deleted)
.env.example (deleted)

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了 1 个问题,并给出了一些整体层面的反馈:

  • 新增的 ATProto DS 设置和 DS 引导对话框(例如在 Settings 和首页对话框中)引入了若干硬编码的英文字符串;由于应用的其他部分都通过 i18n 层来处理文案,建议也把这些字符串接入 translations,以便 DS 相关的 UX 同样可以本地化。
  • apps/ds/lib/signature.ts 中,每个已签名请求都会访问 https://plc.directory 来解析 DID 密钥,而且错误分类是通过对错误消息进行字符串匹配来完成的;如果增加一个短时内存缓存来存储 DID 公钥,并使用显式的错误类型,将可以减少延迟,并让状态处理的方式更加稳健。
  • DS 应用同时在 devDependencies 中声明了 @types/pg,又定义了一个自定义的全局模块 apps/ds/types/pg.d.ts;删除自定义声明,改为依赖官方的 @types/pg 类型会更简洁,也更不容易出错。
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- 新增的 ATProto DS 设置和 DS 引导对话框(例如在 `Settings` 和首页对话框中)引入了若干硬编码的英文字符串;由于应用的其他部分都通过 i18n 层来处理文案,建议也把这些字符串接入 `translations`,以便 DS 相关的 UX 同样可以本地化。
-`apps/ds/lib/signature.ts` 中,每个已签名请求都会访问 `https://plc.directory` 来解析 DID 密钥,而且错误分类是通过对错误消息进行字符串匹配来完成的;如果增加一个短时内存缓存来存储 DID 公钥,并使用显式的错误类型,将可以减少延迟,并让状态处理的方式更加稳健。
- DS 应用同时在 `devDependencies` 中声明了 `@types/pg`,又定义了一个自定义的全局模块 `apps/ds/types/pg.d.ts`;删除自定义声明,改为依赖官方的 `@types/pg` 类型会更简洁,也更不容易出错。

## Individual Comments

### Comment 1
<location path="apps/web/app/(app)/app/page.tsx" line_range="88-86" />
<code_context>
-    return false
-  }, [minimumWaitDone, hasSessionCookie, isLoaded, isSignedIn, dbReady])
-
-  if (shouldShowAuthWait) {
-    return <AuthWaitingLoading />
-  }
</code_context>
<issue_to_address>
**issue (bug_risk):** 没有 DS endpoint 的 ATProto 用户会卡在加载界面,永远看不到 DS 配置对话框。

由于 `shouldShowAuthWait` 只有在 `dbReady``true` 时才会停止返回 `true`,因此一个已经登录 ATProto 但尚未配置 DS 的用户会一直停留在加载状态:`checkDbDataReady` 会打开 DS 对话框(`setDsDialogOpen(true)`),但从不会把 `dbReady` 设为 `true`,所以 `<AuthWaitingLoading />` 会一直被渲染,对话框也就始终不会显示出来。

要修复这个问题,可以选择以下任一方式:
- 调整 `shouldShowAuthWait`,使得 `atprotoSignedIn && !atprotoDs` 不会阻塞主界面(及对话框)的渲染,或者
- 一旦检测到 `sessionData.signedIn && !dsData.ds`,就将 `dbReady` 设为 `true`,以便主树(包括对话框)可以渲染。

例如:
```ts
const shouldShowAuthWait = useMemo(() => {
  if (!minimumWaitDone) return true;
  if (hasSessionCookie && !isLoaded) return true;
  if (isSignedIn && !dbReady && !(atprotoSignedIn && !atprotoDs)) return true;
  return false;
}, [minimumWaitDone, hasSessionCookie, isLoaded, isSignedIn, dbReady, atprotoSignedIn, atprotoDs]);
```
</issue_to_address>

Sourcery 对开源项目免费 —— 如果你觉得我们的评审有帮助,欢迎分享给更多人 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈改进后续的评审。
Original comment in English

Hey - I've found 1 issue, and left some high level feedback:

  • The new ATProto DS settings and DS-onboarding dialog (e.g. in Settings and the home page dialog) introduce several hard-coded English strings; since the rest of the app uses the i18n layer, consider wiring these through translations so DS UX is localized as well.
  • In apps/ds/lib/signature.ts, every signed request hits https://plc.directory to resolve the DID key and error classification is done by string-matching error messages; adding a short-lived in-memory cache for DID public keys and using explicit error types would reduce latency and make status handling less brittle.
  • The DS app declares both @types/pg in devDependencies and a custom ambient apps/ds/types/pg.d.ts module; it would be cleaner and less error-prone to remove the custom declaration and rely on the official @types/pg types.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new ATProto DS settings and DS-onboarding dialog (e.g. in `Settings` and the home page dialog) introduce several hard-coded English strings; since the rest of the app uses the i18n layer, consider wiring these through `translations` so DS UX is localized as well.
- In `apps/ds/lib/signature.ts`, every signed request hits `https://plc.directory` to resolve the DID key and error classification is done by string-matching error messages; adding a short-lived in-memory cache for DID public keys and using explicit error types would reduce latency and make status handling less brittle.
- The DS app declares both `@types/pg` in `devDependencies` and a custom ambient `apps/ds/types/pg.d.ts` module; it would be cleaner and less error-prone to remove the custom declaration and rely on the official `@types/pg` types.

## Individual Comments

### Comment 1
<location path="apps/web/app/(app)/app/page.tsx" line_range="88-86" />
<code_context>
-    return false
-  }, [minimumWaitDone, hasSessionCookie, isLoaded, isSignedIn, dbReady])
-
-  if (shouldShowAuthWait) {
-    return <AuthWaitingLoading />
-  }
</code_context>
<issue_to_address>
**issue (bug_risk):** ATProto users without a DS endpoint get stuck on the loading screen and never see the DS configuration dialog.

Because `shouldShowAuthWait` only stops returning `true` once `dbReady` is `true`, an ATProto-signed-in user with no DS configured never leaves the loading state: `checkDbDataReady` opens the DS dialog (`setDsDialogOpen(true)`) but never sets `dbReady` to `true`, so `<AuthWaitingLoading />` is always rendered and the dialog is never shown.

To fix this, either:
- Adjust `shouldShowAuthWait` so `atprotoSignedIn && !atprotoDs` does not block rendering the main UI (and dialog), or
- Set `dbReady` to `true` once you detect `sessionData.signedIn && !dsData.ds`, so the main tree (including the dialog) can render.

For example:
```ts
const shouldShowAuthWait = useMemo(() => {
  if (!minimumWaitDone) return true;
  if (hasSessionCookie && !isLoaded) return true;
  if (isSignedIn && !dbReady && !(atprotoSignedIn && !atprotoDs)) return true;
  return false;
}, [minimumWaitDone, hasSessionCookie, isLoaded, isSignedIn, dbReady, atprotoSignedIn, atprotoDs]);
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

new CustomEvent("atproto-ds-updated", {
detail: { ds: dsData.ds || null },
}),
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): 没有 DS endpoint 的 ATProto 用户会卡在加载界面,永远看不到 DS 配置对话框。

由于 shouldShowAuthWait 只有在 dbReadytrue 时才会停止返回 true,因此一个已经登录 ATProto 但尚未配置 DS 的用户会一直停留在加载状态:checkDbDataReady 会打开 DS 对话框(setDsDialogOpen(true)),但从不会把 dbReady 设为 true,所以 <AuthWaitingLoading /> 会一直被渲染,对话框也就始终不会显示出来。

要修复这个问题,可以选择以下任一方式:

  • 调整 shouldShowAuthWait,使得 atprotoSignedIn && !atprotoDs 不会阻塞主界面(及对话框)的渲染,或者
  • 一旦检测到 sessionData.signedIn && !dsData.ds,就将 dbReady 设为 true,以便主树(包括对话框)可以渲染。

例如:

const shouldShowAuthWait = useMemo(() => {
  if (!minimumWaitDone) return true;
  if (hasSessionCookie && !isLoaded) return true;
  if (isSignedIn && !dbReady && !(atprotoSignedIn && !atprotoDs)) return true;
  return false;
}, [minimumWaitDone, hasSessionCookie, isLoaded, isSignedIn, dbReady, atprotoSignedIn, atprotoDs]);
Original comment in English

issue (bug_risk): ATProto users without a DS endpoint get stuck on the loading screen and never see the DS configuration dialog.

Because shouldShowAuthWait only stops returning true once dbReady is true, an ATProto-signed-in user with no DS configured never leaves the loading state: checkDbDataReady opens the DS dialog (setDsDialogOpen(true)) but never sets dbReady to true, so <AuthWaitingLoading /> is always rendered and the dialog is never shown.

To fix this, either:

  • Adjust shouldShowAuthWait so atprotoSignedIn && !atprotoDs does not block rendering the main UI (and dialog), or
  • Set dbReady to true once you detect sessionData.signedIn && !dsData.ds, so the main tree (including the dialog) can render.

For example:

const shouldShowAuthWait = useMemo(() => {
  if (!minimumWaitDone) return true;
  if (hasSessionCookie && !isLoaded) return true;
  if (isSignedIn && !dbReady && !(atprotoSignedIn && !atprotoDs)) return true;
  return false;
}, [minimumWaitDone, hasSessionCookie, isLoaded, isSignedIn, dbReady, atprotoSignedIn, atprotoDs]);

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7c95f68c5b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +122 to +126
`${ds.replace(/\/$/, "")}/api/share/${encodeURIComponent(id)}`,
{
cache: "no-store",
headers: {
"x-app-token": appToken,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate DS URL before sending x-app-token

This public endpoint fetches ds from a handle-controlled ATProto record and forwards x-app-token to that URL without any allowlist or origin validation. An attacker can set their DS record to an attacker host and call /api/share/public to exfiltrate DS_APP_TOKEN, breaking the DS trust boundary (and enabling server-side requests to arbitrary/internal hosts).

Useful? React with 👍 / 👎.

Comment on lines +16 to +17
"SELECT user_id, share_id, data, timestamp FROM shares WHERE share_id = $1 LIMIT 1",
[shareId],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope DS share lookup to owner DID

This query looks up shares only by share_id, but writes are keyed by (user_id, share_id), so identical IDs from different users on the same DS are valid. In that case, public reads can return the wrong row, allowing one user to shadow/spoof another user's shared link data.

Useful? React with 👍 / 👎.

Comment on lines +70 to +74
const cleanupRes = await signedDsFetch({
session: atproto,
ds: fromDs,
path: "/api/migrate/cleanup",
method: "POST",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update DS record before source cleanup

The migration flow deletes source DS data before updating the ATProto DS pointer. If the subsequent putRecord call fails (e.g., transient PDS/network failure), the user record still points to fromDs even though data there was already removed, causing apparent data loss/unavailability until manual repair.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant