diff --git a/.agents/skills/minimal-sufficient-work/SKILL.md b/.agents/skills/minimal-sufficient-work/SKILL.md new file mode 100644 index 000000000..f52b76f95 --- /dev/null +++ b/.agents/skills/minimal-sufficient-work/SKILL.md @@ -0,0 +1,120 @@ +--- +name: minimal-sufficient-work +description: "Use when MSW is invoked or work risks expanding beyond the requested scope." +--- + +# Minimal Sufficient Work (MSW) + +Use the MSW kernel to complete the requested outcome without speculative work, extra process, or proof beyond what the outcome requires. + +## Program + +``` +contract ← the requested outcome + the smallest criteria that prove it + +while ∃ claim c : deleting c leaves contract unmet ∨ unproven + do c ; prove c + +halt ; report +``` + +## Definitions + +### Contract + +Establish the requested outcome and the smallest set of acceptance criteria that would prove it before doing work. Treat the contract as both the floor and the ceiling. + +When the request is ambiguous: + +- In attended work, ask only when the answer would materially change the contract. +- In unattended work, bind the smallest reading consistent with the stated intent and record the assumption. + +Do not silently expand the contract with inferred improvements, generic best practices, or unrelated cleanup. + +### Claim + +Treat anything petitioning to become work as a claim, including: + +- a plan step or implementation change +- a test, check, investigation, or proof step +- a new file, abstraction, dependency, artifact, or process +- a review finding or discovered edge case +- a migration, rollout control, monitoring change, or cleanup task +- an instinct that another pass would be useful + +A claim is a proposal, not a verdict. Its source or stated severity does not make it necessary. + +### Deletion Rule + +For every claim `c`, ask: + +> If `c` is deleted, does the contract remain met and proven for the task's actual inputs, environment, and applicable constraints? + +- If yes, reject the claim. Do not implement, investigate, defer, or create follow-up work for it. +- If no, accept the claim and perform the smallest reliable act that closes the specific gap. + +A claim passes only when deleting it leaves the contract unmet or unproven. Useful, thorough, conventional, and possible are not synonyms for necessary. Derive severity from impact on the contract, not from the person or tool that raised the claim. + +### Do and Prove + +For each accepted claim: + +1. Perform the smallest reliable act that closes the gap. +2. Produce evidence sized to that claim. +3. Close the claim when the evidence proves it. + +An unproven act leaves its claim open. Re-proving a closed claim is a new claim and must pass the deletion rule. + +Use evidence appropriate to the gap, such as a focused test, command output, direct inspection, reproduced behavior, type check, build result, or authoritative documentation. Do not add a broader test or review pass unless deleting it would leave the contract unproven. + +### Halt + +Stop when the contract is proven and no remaining claim passes the deletion rule. Reviewer silence and exhausted imagination are not stopping conditions. Continuing after the fixed point is as incorrect as stopping before it. + +## Fuses + +Use these fuses when claim evaluation fails to converge: + +```text +rounds = 3 -> halt and report open items; do not chase them +claim born in round n+1 that was visible in round n -> reject +``` + +A round is one complete evaluation, action, and proof pass over the claims then visible. The three-round limit is defined by this skill, not inferred from task size. + +## No Unauthoritative Limits + +Never invent a cap, threshold, quota, budget, timeout, retry count, round count, file count, line count, acceptance-criterion count, agent count, or similar limit. + +A limit is admissible only when its exact value is: + +- explicitly required by the requester +- imposed by an applicable technical or platform contract +- defined by authoritative project policy +- derived from measured evidence necessary to meet or prove the contract + +State the authority or derivation whenever proposing or applying a limit. If no authority exists, omit the limit and apply the deletion rule. Metrics may be evidence, but must not become gates, defaults, targets, or recommendations through agent intuition. Examples and representative proportions do not become defaults. + +If a necessary limit requires an unresolved owner choice, ask rather than manufacture a value. The fuse above is already authorized by this skill. + +## Report + +Report only: + +1. the outcome against the contract +2. the evidence that proves it +3. necessary open items remaining because a fuse fired or the work is blocked +4. rejected claims worth the user's attention, at most one line each + +Do not turn rejected claims into recommendations, investigations, or deferred tasks. + +## Quality Gate + +Before reporting, confirm: + +- the requested outcome and proof criteria formed the contract +- every performed action passed the deletion rule +- every accepted claim has evidence or remains explicitly open +- no invented limit shaped the work +- no closed claim was re-proven without necessity +- work stopped at the fixed point or an authorized fuse diff --git a/.env.example b/.env.example index 12fac543c..9286b14d6 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # Postgres -DATABASE_URL="postgresql://postgres:postgres@localhost:5432/crm?schema=public" +DATABASE_URL="postgresql://crm:crm@localhost:5432/crm?schema=public" # A direct, unpooled connection to that same database, used by # `prisma migrate deploy` when the API is built on Vercel. Set it only if @@ -11,7 +11,12 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/crm?schema=public" # and refuses to run without this — these are real integration tests, they write # and delete rows, and the pre-push hook runs them. The name has to end in # `_test`. `bun run db:test` creates it and applies the migrations. -TEST_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/crm_test?schema=public" +TEST_DATABASE_URL="postgresql://crm:crm@localhost:5432/crm_test?schema=public" + +# Postgres connection for a BYPASSRLS role. Only the database migration +# rehearsal uses this role to audit every tenant. Normal app processes must +# never use this value. +AUDIT_DATABASE_URL="" # Generate your own: openssl rand -base64 32 BETTER_AUTH_SECRET="" @@ -53,7 +58,7 @@ GOOGLE_CLIENT_SECRET="" # MICROSOFT_CLIENT_SECRET="" # Optional. Enables Slack account linking on Settings > Connections. -# Add APP_URL + /api/auth/oauth2/callback/slack as the Slack OAuth redirect URL. +# Add API_URL + /api/auth/callback/slack as the Slack OAuth redirect URL. # SLACK_CLIENT_ID="" # SLACK_CLIENT_SECRET="" @@ -112,6 +117,41 @@ GOOGLE_CLIENT_SECRET="" # schedule. # AGENT_BRIDGE_SECRET="" +# Optional XMPP agent gateway. Set XMPP_COMPONENT_ENABLED to 1 and provide the +# component identity, component secret, and owning organization together. The +# gateway exposes only operations from apps/agent/src/exports. Task state is +# retained in PostgreSQL for 24 hours after admission. +# XMPP_COMPONENT_ENABLED="1" +# XMPP_COMPONENT_JID="gateway.agents.example.com" +# XMPP_COMPONENT_SECRET="" +# XMPP_ORGANIZATION_ID="" +# XMPP_COMPONENT_SERVICE="xmpp://127.0.0.1:5275" +# XMPP_DEFAULT_AGENT_JID="assistant@agents.example.com" +# XMPP_AGENT_DOMAIN="agents.example.com" +# XMPP_SERVER_DOMAIN="example.com" +# XMPP_GATEWAY_ID="gw-1" +# XMPP_AGENT_VERSION="1.0.0" + +# Comma-separated domains that can discover and invoke the endpoint. The +# server and agent domains are used when this value is absent. +# XMPP_ALLOWED_CALLER_DOMAINS="example.com,agents.example.com" + +# Comma-separated bare JIDs that can invoke destructive exports. Destructive +# exports remain visible but return forbidden when this value is absent. +# XMPP_ALLOW_DESTRUCTIVE_CALLERS="trusted-agent@agents.example.com" + +# Optional transport tuning. The copied gateway supplies safe defaults. +# XMPP_XML_LANG="en" +# XMPP_RECEIPT_TIMEOUT_MS="30000" +# XMPP_RECEIPT_MAX_RESENDS="0" +# XMPP_RECEIPT_SWEEP_MS="10000" +# XMPP_RECONNECT_INITIAL_MS="1000" +# XMPP_RECONNECT_MAX_MS="60000" +# XMPP_PING_INTERVAL_MS="60000" +# XMPP_PING_TIMEOUT_MS="10000" +# XMPP_PING_FAILURE_THRESHOLD="2" +# XMPP_MAX_PENDING_IQ_REQUESTS="256" + # PORT="3001" @@ -152,6 +192,14 @@ GOOGLE_CLIENT_SECRET="" # https://vercel.com/docs/ai-gateway # AI_GATEWAY_API_KEY="" +# Cloudflare R2 storage. Leave all four values empty to disable asset uploads. +# Create an R2 API token with object read and write access for the private bucket. +# The API signs direct browser and mobile transfers. It does not expose these keys. +# R2_ACCOUNT_ID="" +# R2_ACCESS_KEY_ID="" +# R2_SECRET_ACCESS_KEY="" +# R2_BUCKET="" + # ── Optional: operations ───────────────────────────────────────────────────── diff --git a/.github/workflows/auto-pr.yml b/.github/workflows/auto-pr.yml deleted file mode 100644 index bbfcd8ec6..000000000 --- a/.github/workflows/auto-pr.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Auto PR - -on: - push: - branches-ignore: - - main - - release - - "release-please--**" - - "gh-readonly-queue/**" - - "dependabot/**" - -concurrency: - group: auto-pr-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - -jobs: - open: - name: Open a pull request into main - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Open the pull request - env: - GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN || secrets.GITHUB_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - BRANCH: ${{ github.ref_name }} - run: | - set -euo pipefail - - existing=$(gh pr list --head "$BRANCH" --state all --limit 1 --json number,state --jq '.[0] | select(.) | "#\(.number) (\(.state))"') - if [ -n "$existing" ]; then - echo "\`$BRANCH\` already has pull request $existing — leaving it alone." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - if [ "$(git rev-list --count "origin/main..origin/$BRANCH")" = "0" ]; then - echo "\`$BRANCH\` has nothing \`main\` does not already have." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - title=$(.github/scripts/pr-title.sh generate "origin/main" "origin/$BRANCH" "$BRANCH") - - body=$(cat < - EOF - ) - - gh pr create --base main --head "$BRANCH" --title "$title" --body "$body" - - echo "Opened a pull request for \`$BRANCH\` titled \`$title\`." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf7714763..09695ece5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: pull_request: types: [opened, synchronize, reopened, ready_for_review] push: - branches: [main, release] + branches: [master] concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -20,12 +20,12 @@ env: jobs: check: name: check-types, lint, test - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 timeout-minutes: 20 services: postgres: - image: postgres:17-alpine + image: postgres:18-alpine env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -39,8 +39,8 @@ jobs: --health-retries 20 env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/crm_test?schema=public - TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/crm_test?schema=public + DATABASE_URL: postgresql://crm:crm@localhost:5432/crm_test?schema=public + TEST_DATABASE_URL: postgresql://crm:crm@localhost:5432/crm_test?schema=public ALLOWED_SIGN_IN: example.com BETTER_AUTH_SECRET: ci-only-secret-regenerate-for-any-real-deployment API_URL: http://localhost:3001 @@ -50,13 +50,30 @@ jobs: steps: - uses: actions/checkout@v5 + with: + submodules: recursive - uses: oven-sh/setup-bun@v2 with: bun-version-file: package.json + - name: Create runtime database role + env: + PGPASSWORD: postgres + run: | + psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 -c "CREATE ROLE crm WITH LOGIN PASSWORD 'crm' NOSUPERUSER CREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS" + psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 -c "ALTER DATABASE crm_test OWNER TO crm" + - run: bun install --frozen-lockfile + - name: Install kaneo dependencies + run: cd vendor/kaneo && bun install + + - name: Build kaneo packages + run: | + cd vendor/kaneo/packages/email && bun run build + cd ../permissions && bun run build + - name: Apply migrations run: bun run db:deploy diff --git a/.github/workflows/pr-base.yml b/.github/workflows/pr-base.yml deleted file mode 100644 index 6f62f04b5..000000000 --- a/.github/workflows/pr-base.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: PR base - -on: - pull_request_target: - types: [opened, reopened] - branches: [release] - -concurrency: - group: pr-base-${{ github.event.pull_request.number }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - -jobs: - retarget: - name: Retarget onto main - if: >- - github.event.pull_request.head.ref != 'main' && - !startsWith(github.event.pull_request.head.ref, 'release-please--') - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - name: Move the pull request to main - env: - GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN || secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - gh pr edit "$NUMBER" --base main - - echo "Retargeted #$NUMBER from \`release\` onto \`main\`." >> "$GITHUB_STEP_SUMMARY" - - - uses: marocchino/sticky-pull-request-comment@v2 - continue-on-error: true - with: - header: pr-base - message: | - **Retargeted this onto `main`.** - - `release` is the default branch so that a plain clone runs the last tagged release, but nothing merges into it — it is fast-forwarded onto the tag by the Release workflow and that is all. Changes go to `main`, and reach `release` when a release is cut. - - Nothing is wrong with your branch. If the diff now shows commits that are already on `main`, rebase and force-push: - - ```sh - git fetch origin main - git rebase origin/main - git push --force-with-lease - ``` diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml deleted file mode 100644 index c289adbe8..000000000 --- a/.github/workflows/pr-title.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: PR title - -on: - pull_request: - types: [opened, edited, reopened, ready_for_review, synchronize] - branches: [main] - -concurrency: - group: pr-title-${{ github.event.pull_request.number }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - -jobs: - conventional: - name: conventional commit - if: >- - github.event.pull_request.draft == false && - !startsWith(github.event.pull_request.head.ref, 'release-please--') - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Write the title from the diff - id: autotitle - env: - GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN || secrets.GITHUB_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - TITLE: ${{ github.event.pull_request.title }} - BODY: ${{ github.event.pull_request.body }} - NUMBER: ${{ github.event.pull_request.number }} - BRANCH: ${{ github.event.pull_request.head.ref }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - - body=$(printf '%s' "$BODY" | tr -d '\r') - - if ! .github/scripts/pr-title.sh check "$TITLE"; then - reason="it is not a release note" - elif ! .github/scripts/pr-title.sh sufficient "$BASE_SHA" "$HEAD_SHA" "$TITLE"; then - reason="it releases less than the commits on the branch do" - else - echo "\`$TITLE\` still covers the diff — leaving it alone." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - title=$(.github/scripts/pr-title.sh generate "$BASE_SHA" "$HEAD_SHA" "$BRANCH") - - if [ "$title" = "$TITLE" ]; then - echo "\`$TITLE\` still describes the diff." >> "$GITHUB_STEP_SUMMARY" - echo "written=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - body=$(printf '%s\n\n\n' \ - "$(printf '%s\n' "$body" | grep -v '^>Flutter: Authorization code through claimed link + Flutter->>Auth: Code exchange with PKCE verifier + Auth-->>Flutter: Access, ID, and refresh tokens + Flutter->>API: Bearer access token + API->>API: Verify signature, issuer, audience, and scope + API->>Services: User identity and current workspace context + Services-->>Flutter: Role-authorized response +``` + +### Implement the Flutter OIDC flow + +Use `flutter_appauth` for Android, iOS, and macOS. + +It uses the system browser and supports discovery, Authorization Code, and PKCE. + +Configure the CRM issuer, public client identifier, redirect URI, resource, and scopes. + +Read authorization and token endpoints from OIDC discovery. + +Do not hardcode provider-specific Google or Microsoft endpoints. + +Request `openid`, `profile`, `email`, `offline_access`, `crm.read`, and `crm.write`. + +Use the access token in `Authorization: Bearer `. + +Never send the ID token to the REST API. + +Keep the current access token in memory. + +Store the rotated refresh token in platform secure storage. + +Store only the minimum identity data required for startup presentation. + +Serialize refresh operations through one in-flight operation. + +Retry one failed request after a successful refresh. + +Do not create a refresh loop after another `401` response. + +Delete tokens after `invalid_grant`, explicit sign-out, or device revocation. + +Treat user cancellation as a normal signed-out result. + +Do not use an embedded web view for sign-in. + +Do not send tokens through application links. + +Only the short-lived authorization code returns through the link. + +### Roll out OAuth safely + +Implement the work in bounded phases. + +1. The CRM aligns Better Auth runtime packages. +2. The CRM adds provider plugins, migrations, discovery, and the public client. +3. The CRM adds bearer verification and OpenAPI bearer security. +4. The CRM enforces `crm.read` and `crm.write` before service authorization. +5. The Flutter application adds AppAuth sign-in, secure refresh, and sign-out. +6. A later CRM change adds device-session management and revocation. +7. API keys remain available for automation. + +Do not remove cookie sessions or API keys during the first release. + +Run old and new authentication paths through the same authorization tests. + +### OAuth and OIDC references + +- [Better Auth OAuth 2.1 Provider](https://better-auth.com/docs/plugins/oauth-provider) +- [Better Auth resource-server verification](https://better-auth.com/docs/plugins/oauth-provider#api-server) +- [Better Auth JWT plugin](https://better-auth.com/docs/plugins/jwt) +- [Better Auth bearer-session plugin](https://better-auth.com/docs/plugins/bearer) +- [Flutter AppAuth package](https://pub.dev/packages/flutter_appauth) + +### Test the Flutter integration + +Use an injected HTTP client in unit tests. + +Test these cases before release. + +| Test | Expected result | +| --- | --- | +| Request targets the CRM origin | The client adds `x-api-key` | +| Request targets another origin | The client omits `x-api-key` | +| Credential storage returns null | The client enters `signedOut` | +| Workspace bootstrap returns 401 | The client deletes the stored key | +| Workspace bootstrap returns 403 | The client preserves the stored key | +| A member opens management UI | Restricted controls stay disabled | +| The server returns 429 | The client respects `Retry-After` | +| A mutation loses its response | The client does not retry automatically | +| The OpenAPI document changes | Generated client drift fails continuous integration | +| Logging captures a request | Credential headers remain redacted | +| Discovery reports the wrong issuer | The authentication test fails | +| A public client omits PKCE | The authorization request fails | +| An access token has another audience | The REST request returns 401 | +| An access token lacks `crm.read` | A protected read returns 403 | +| A refresh token is reused | The token family follows the configured reuse policy | +| A device session is revoked | Its next refresh fails | + +Run an integration test against the current API release. + +Use a dedicated test user and a short-lived API key. + +Revoke that key after the test suite. + +Never use a production owner key in automated tests. + +### Platform requirements + +Android applications need the `INTERNET` permission. + +macOS applications need the network client entitlement. + +iOS and Android builds need secure storage configuration. + +Production applications must use HTTPS. + +Development cleartext exceptions must target local development hosts only. + +### Flutter implementation references + +- [Flutter networking guidance](https://docs.flutter.dev/data-and-backend/networking) +- [Flutter authenticated request example](https://docs.flutter.dev/cookbook/networking/authenticated-requests) +- [OpenAPI Generator Dart Dio documentation](https://openapi-generator.tech/docs/generators/dart-dio/) +- [Flutter secure storage package](https://pub.dev/packages/flutter_secure_storage) + +## Request validation and errors + +tRPC inputs use Zod schemas. + +The REST bridge uses the same schemas and services. + +Native DTO validation removes unknown properties. + +Native DTO validation also rejects non-whitelisted properties. + +The global pipe enables implicit type conversion. + +List procedures filter, sort, and paginate inside Prisma. + +Typical list responses use this shape: + +```json +{ + "rows": [], + "total": 0, + "facetCounts": {} +} +``` + +Domain services throw Nest HTTP exceptions. + +The tRPC domain middleware maps selected statuses. + +| HTTP status | tRPC code | +| ---: | --- | +| 400 | `BAD_REQUEST` | +| 401 | `UNAUTHORIZED` | +| 403 | `FORBIDDEN` | +| 404 | `NOT_FOUND` | +| 409 | `CONFLICT` | +| 429 | `TOO_MANY_REQUESTS` | +| Other | `INTERNAL_SERVER_ERROR` | + +Zod errors receive readable messages. + +Do not depend on internal stack traces or database error text. + +## OpenAPI behavior + +Swagger UI is available at `/`. + +The JSON document is available at `/openapi.json`. + +The document is built at runtime. + +It merges Nest controller metadata with the tRPC REST bridge. + +The document does not come from a committed artifact. + +Router changes therefore change the runtime document. + +Every `restMeta` call defaults to protected. + +Only `sso.signInOptions` sets `protect: false`. + +All tRPC REST metadata uses root resource paths. + +The document contains no tRPC REST compatibility aliases. + +The document declares three security schemes. + +| Scheme | Location | Name | +| --- | --- | --- | +| Cookie | Cookie header | `crm.session_token` | +| API key | Header | `x-api-key` | +| OAuth bearer | Authorization header | `Bearer ` | + +Better Auth owns its routes independently. + +Its full mounted route set is larger than the Swagger-supported CRM contract. + +## Native controller inventory + +| Method | Path | Access | Purpose | OpenAPI | +| --- | --- | --- | --- | --- | +| GET | `/auth/me` | Session | Read the current user profile | Included | +| GET | `/auth/session` | Optional session | Read session state | Included | +| GET | `/health` | Public | Check API and database | Included | +| GET | `/api/conversations/attachments/:id` | Session | Download an attachment | Included | +| GET | `/api/t/config/:siteId` | Public | Read tracking configuration | Included | +| POST | `/api/t/e` | Public | Collect tracking events | Included | +| GET | `/internal/sync/mailboxes` | Cron bearer | Run due mailbox work | Included | +| POST | `/internal/sync/mailboxes` | Cron bearer | Legacy scheduler method | Excluded | +| GET | `/internal/sync/google` | Cron bearer | Mailbox alias | Included | +| POST | `/internal/sync/google` | Cron bearer | Legacy mailbox alias | Excluded | +| GET | `/internal/sync/rates` | Cron bearer | Refresh exchange rates | Included | +| POST | `/internal/sync/rates` | Cron bearer | Legacy scheduler method | Excluded | +| GET | `/internal/telemetry/rollup` | Cron bearer | Roll up telemetry | Included | +| POST | `/internal/telemetry/rollup` | Cron bearer | Legacy scheduler method | Excluded | +| GET | `/internal/archive/prune` | Cron bearer | Purge expired archives | Included | +| POST | `/internal/archive/prune` | Cron bearer | Legacy scheduler method | Excluded | +| GET | `/internal/tracking/retention` | Cron bearer | Sweep tracking data | Included | +| POST | `/internal/tracking/retention` | Cron bearer | Legacy scheduler method | Excluded | + +The attachment endpoint accepts an optional `share` query parameter. + +The global session guard still runs before the controller method. + +## Better Auth mounted route inventory + +This table reports the Better Auth paths that CompCRM uses or exposes intentionally. + +A mounted path is not always enabled or supported. + +Email-password operations remain mounted but reject their disabled flow. + +Organization creation and deletion are disabled by configuration. + +The CRM product supports OIDC SSO configuration through `sso.*`. + +The SAML protocol routes come from the Better Auth SSO plugin. + +| Method | Path | Better Auth operation | +| --- | --- | --- | +| POST | `/api/auth/sign-in/social` | `signInSocial` | +| GET / POST | `/api/auth/callback/:id` | `callbackOAuth` | +| GET / POST | `/api/auth/get-session` | `getSession` | +| POST | `/api/auth/sign-out` | `signOut` | +| POST | `/api/auth/sign-up/email` | `signUpEmail` | +| POST | `/api/auth/sign-in/email` | `signInEmail` | +| POST | `/api/auth/reset-password` | `resetPassword` | +| POST | `/api/auth/verify-password` | `verifyPassword` | +| GET | `/api/auth/verify-email` | `verifyEmail` | +| POST | `/api/auth/send-verification-email` | `sendVerificationEmail` | +| POST | `/api/auth/change-email` | `changeEmail` | +| POST | `/api/auth/change-password` | `changePassword` | +| POST | `/api/auth/update-session` | `updateSession` | +| POST | `/api/auth/update-user` | `updateUser` | +| POST | `/api/auth/delete-user` | `deleteUser` | +| POST | `/api/auth/request-password-reset` | `requestPasswordReset` | +| GET | `/api/auth/reset-password/:token` | `requestPasswordResetCallback` | +| GET | `/api/auth/list-sessions` | `listSessions` | +| POST | `/api/auth/revoke-session` | `revokeSession` | +| POST | `/api/auth/revoke-sessions` | `revokeSessions` | +| POST | `/api/auth/revoke-other-sessions` | `revokeOtherSessions` | +| POST | `/api/auth/link-social` | `linkSocialAccount` | +| GET | `/api/auth/list-accounts` | `listUserAccounts` | +| GET | `/api/auth/delete-user/callback` | `deleteUserCallback` | +| POST | `/api/auth/unlink-account` | `unlinkAccount` | +| POST | `/api/auth/refresh-token` | `refreshToken` | +| POST | `/api/auth/get-access-token` | `getAccessToken` | +| GET | `/api/auth/account-info` | `accountInfo` | +| POST | `/api/auth/organization/create` | `createOrganization` | +| POST | `/api/auth/organization/update` | `updateOrganization` | +| POST | `/api/auth/organization/delete` | `deleteOrganization` | +| POST | `/api/auth/organization/set-active` | `setActiveOrganization` | +| GET | `/api/auth/organization/get-full-organization` | `getFullOrganization` | +| GET | `/api/auth/organization/list` | `listOrganizations` | +| POST | `/api/auth/organization/invite-member` | `createInvitation` | +| POST | `/api/auth/organization/cancel-invitation` | `cancelInvitation` | +| POST | `/api/auth/organization/accept-invitation` | `acceptInvitation` | +| GET | `/api/auth/organization/get-invitation` | `getInvitation` | +| POST | `/api/auth/organization/reject-invitation` | `rejectInvitation` | +| GET | `/api/auth/organization/list-invitations` | `listInvitations` | +| GET | `/api/auth/organization/get-active-member` | `getActiveMember` | +| POST | `/api/auth/organization/check-slug` | `checkOrganizationSlug` | +| POST | `/api/auth/organization/remove-member` | `removeMember` | +| POST | `/api/auth/organization/update-member-role` | `updateMemberRole` | +| POST | `/api/auth/organization/leave` | `leaveOrganization` | +| GET | `/api/auth/organization/list-user-invitations` | `listUserInvitations` | +| GET | `/api/auth/organization/list-members` | `listMembers` | +| GET | `/api/auth/organization/get-active-member-role` | `getActiveMemberRole` | +| POST | `/api/auth/organization/has-permission` | `hasPermission` | +| GET | `/api/auth/sso/saml2/sp/metadata` | `spMetadata` | +| POST | `/api/auth/sso/register` | `registerSSOProvider` | +| POST | `/api/auth/sign-in/sso` | `signInSSO` | +| GET | `/api/auth/sso/callback/:providerId` | `callbackSSO` | +| GET | `/api/auth/sso/callback` | `callbackSSOShared` | +| GET / POST | `/api/auth/sso/saml2/callback/:providerId` | `callbackSSOSAML` | +| POST | `/api/auth/sso/saml2/sp/acs/:providerId` | `acsEndpoint` | +| GET / POST | `/api/auth/sso/saml2/sp/slo/:providerId` | `sloEndpoint` | +| POST | `/api/auth/sso/saml2/logout/:providerId` | `initiateSLO` | +| GET | `/api/auth/sso/providers` | `listSSOProviders` | +| GET | `/api/auth/sso/get-provider` | `getSSOProvider` | +| POST | `/api/auth/sso/update-provider` | `updateSSOProvider` | +| POST | `/api/auth/sso/delete-provider` | `deleteSSOProvider` | +| POST | `/api/auth/api-key/create` | `createApiKey` | +| GET | `/api/auth/api-key/get` | `getApiKey` | +| POST | `/api/auth/api-key/update` | `updateApiKey` | +| POST | `/api/auth/api-key/delete` | `deleteApiKey` | +| GET | `/api/auth/api-key/list` | `listApiKeys` | +| GET | `/api/auth/oauth2/authorize` | `oauth2Authorize` | +| POST | `/api/auth/oauth2/token` | `oauth2Token` | +| POST | `/api/auth/oauth2/consent` | `oauth2Consent` | +| POST | `/api/auth/oauth2/continue` | `oauth2Continue` | +| GET / POST | `/api/auth/oauth2/userinfo` | `oauth2UserInfo` | +| POST | `/api/auth/oauth2/introspect` | `oauth2Introspect` | +| POST | `/api/auth/oauth2/revoke` | `oauth2Revoke` | +| GET / POST | `/api/auth/oauth2/end-session` | `oauth2EndSession` | +| GET / POST | `/api/auth/oauth2/end-session/confirm` | `oauth2EndSessionConfirmation` | +| GET | `/api/auth/.well-known/openid-configuration` | `getOpenIdConfig` | +| GET | `/api/auth/.well-known/oauth-authorization-server` | `getOAuthServerConfig` | +| GET | `/api/auth/jwks` | `getJwks` | +| GET | `/api/auth/ok` | `ok` | +| GET | `/api/auth/error` | `error` | + +## tRPC and REST bridge inventory + +The access values use these meanings. + +| Access value | Meaning | +| --- | --- | +| `public` | No session or API key | +| `session-only` | Browser session required | +| `session-or-api-key` | Browser session, valid `x-api-key`, or OAuth access token | + +The input and output names refer to Zod schemas in router contract modules. + + +### `activities` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `activities.timeline` | query | `GET /activities` | `timelineInput` | `timelineOutput` | session-or-api-key | +| `activities.timelineCounts` | query | `GET /activities/counts` | `timelineCountsInput` | `timelineCountsOutput` | session-or-api-key | +| `activities.myTasks` | query | `GET /activities/my-tasks` | `myTasksInput` | `myTasksOutput` | session-or-api-key | +| `activities.create` | mutation | `POST /activities` | `activityCreateInput` | `activityCreateOutput` | session-or-api-key | +| `activities.complete` | mutation | `PATCH /activities/{id}/complete` | `completeInput` | `completeOutput` | session-or-api-key | + +### `agents` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `agents.list` | query | `GET /agents` | `none` | `agentListOutput` | session-or-api-key | +| `agents.revise` | mutation | `POST /agents/{id}/revise` | `agentReviseInput` | `agentReviseOutput` | session-or-api-key | +| `agents.files` | query | `GET /agents/{id}/files` | `agentIdInput` | `agentFilesOutput` | session-or-api-key | +| `agents.saveFile` | mutation | `POST /agents/{id}/save-file` | `agentSaveFileInput` | `agentSaveFileOutput` | session-or-api-key | +| `agents.byId` | query | `GET /agents/{id}` | `agentIdInput` | `agentByIdOutput` | session-or-api-key | +| `agents.history` | query | `GET /agents/{id}/history` | `agentHistoryInput` | `agentHistoryOutput` | session-or-api-key | +| `agents.activity` | query | `GET /agents/{id}/activity` | `agentHistoryInput` | `agentActivityOutput` | session-or-api-key | +| `agents.update` | mutation | `PATCH /agents/{id}` | `agentUpdateInput` | `agentUpdateOutput` | session-or-api-key | +| `agents.deploy` | mutation | `POST /agents/{id}/deploy` | `agentDeployInput` | `agentDeployOutput` | session-or-api-key | +| `agents.pause` | mutation | `POST /agents/{id}/pause` | `agentIdInput` | `agentPauseOutput` | session-or-api-key | +| `agents.resume` | mutation | `POST /agents/{id}/resume` | `agentIdInput` | `agentResumeOutput` | session-or-api-key | +| `agents.archive` | mutation | `POST /agents/{id}/archive` | `agentIdInput` | `agentArchiveOutput` | session-or-api-key | +| `agents.restore` | mutation | `POST /agents/{id}/restore` | `agentIdInput` | `agentRestoreOutput` | session-or-api-key | +| `agents.remove` | mutation | `DELETE /agents/{id}` | `agentIdInput` | `agentRemoveOutput` | session-or-api-key | +| `agents.runNow` | mutation | `POST /agents/{id}/run` | `agentRunNowInput` | `agentRunNowOutput` | session-or-api-key | +| `agents.retryRun` | mutation | `POST /agents/{id}/runs/{runId}/retry` | `agentRetryRunInput` | `agentRetryRunOutput` | session-or-api-key | +| `agents.cancelRun` | mutation | `POST /agents/{id}/runs/{runId}/cancel` | `agentCancelRunInput` | `agentCancelRunOutput` | session-or-api-key | + +### `apiKeys` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `apiKeys.list` | query | `GET /api-keys` | `apiKeyListInput` | `apiKeyListOutput` | session-only | +| `apiKeys.create` | mutation | `POST /api-keys` | `createApiKeyInput` | `createApiKeyOutput` | session-only | +| `apiKeys.revoke` | mutation | `DELETE /api-keys/{id}` | `revokeApiKeyInput` | `revokeApiKeyOutput` | session-only | + +### `appointments` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `appointments.createAppointment` | mutation | `POST /projects/{projectId}/appointments` | `projectAppointmentCreateInput` | `appointmentDetailSchema` | session-or-api-key | +| `appointments.listAppointments` | query | `GET /projects/{projectId}/appointments` | `projectAppointmentListInput` | `appointmentListSchema` | session-or-api-key | +| `appointments.getAppointment` | query | `GET /projects/{projectId}/appointments/{appointmentId}` | `projectAppointmentInput` | `appointmentDetailSchema` | session-or-api-key | +| `appointments.updateAppointment` | mutation | `PATCH /projects/{projectId}/appointments/{appointmentId}` | `projectAppointmentUpdateInput` | `appointmentDetailSchema` | session-or-api-key | +| `appointments.archiveAppointment` | mutation | `DELETE /projects/{projectId}/appointments/{appointmentId}` | `projectAppointmentInput` | `appointmentArchiveSchema` | session-or-api-key | + +### `assets` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `assets.createProjectAsset` | mutation | `POST /projects/{projectId}/assets` | `projectAssetCreateInput` | `assetCreationSchema` | session-or-api-key | +| `assets.createAppointmentAsset` | mutation | `POST /appointments/{appointmentId}/assets` | `appointmentAssetCreateInput` | `assetCreationSchema` | session-or-api-key | +| `assets.listProjectAssets` | query | `GET /projects/{projectId}/assets` | `projectAssetListInput` | `assetListSchema` | session-or-api-key | +| `assets.listAppointmentAssets` | query | `GET /appointments/{appointmentId}/assets` | `appointmentAssetListInput` | `assetListSchema` | session-or-api-key | +| `assets.getAsset` | query | `GET /assets/{assetId}` | `assetMemberInput` | `assetDetailSchema` | session-or-api-key | +| `assets.updateAsset` | mutation | `PATCH /assets/{assetId}` | `assetUpdateInput` | `assetDetailSchema` | session-or-api-key | +| `assets.deleteAsset` | mutation | `DELETE /assets/{assetId}` | `assetMemberInput` | `assetDeletionSchema` | session-or-api-key | + +### `companies` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `companies.list` | query | `POST /companies/search` | `companyListInput` | `companyListOutput` | session-or-api-key | +| `companies.byId` | query | `GET /companies/{id}` | `companyIdInput` | `companyDetailOutput` | session-or-api-key | +| `companies.options` | query | `GET /companies/options` | `companyOptionsInput` | `companyOptionOutput` | session-or-api-key | +| `companies.create` | mutation | `POST /companies` | `companyCreateInput` | `companySummaryOutput` | session-or-api-key | +| `companies.update` | mutation | `PATCH /companies/{id}` | `companyUpdateArgs` | `companySummaryOutput` | session-or-api-key | +| `companies.archive` | mutation | `POST /companies/{id}/archive` | `companyIdInput` | `companyArchiveResultOutput` | session-or-api-key | +| `companies.restore` | mutation | `POST /companies/{id}/restore` | `companyIdInput` | `companyArchiveResultOutput` | session-or-api-key | +| `companies.purge` | mutation | `DELETE /companies/{id}` | `companyIdInput` | `companyArchiveResultOutput` | session-or-api-key | +| `companies.bulkAssignOwner` | mutation | `POST /companies/bulk-assign-owner` | `companyBulkOwnerInput` | `companyBulkResultOutput` | session-or-api-key | +| `companies.bulkEnrich` | mutation | `POST /companies/bulk-enrich` | `companyBulkInput` | `companyBulkResultOutput` | session-or-api-key | +| `companies.bulkArchive` | mutation | `POST /companies/bulk-archive` | `companyBulkInput` | `companyBulkResultOutput` | session-or-api-key | +| `companies.bulkRestore` | mutation | `POST /companies/bulk-restore` | `companyBulkInput` | `companyBulkResultOutput` | session-or-api-key | +| `companies.bulkPurge` | mutation | `POST /companies/bulk-purge` | `companyBulkInput` | `companyBulkResultOutput` | session-or-api-key | +| `companies.enrich` | mutation | `POST /companies/{id}/enrich` | `companyIdInput` | `companyEnrichOutput` | session-or-api-key | +| `companies.research` | mutation | `POST /companies/{id}/research` | `companyIdInput` | `companyResearchOutput` | session-or-api-key | +| `companies.setPrimaryContact` | mutation | `POST /companies/{companyId}/set-primary-contact` | `setPrimaryContactInput` | `companySetPrimaryContactOutput` | session-or-api-key | + +### `contacts` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `contacts.list` | query | `POST /contacts/search` | `contactListInput` | `contactListOutput` | session-or-api-key | +| `contacts.byId` | query | `GET /contacts/{id}` | `contactIdInput` | `contactByIdOutput` | session-or-api-key | +| `contacts.create` | mutation | `POST /contacts` | `contactCreateInput` | `contactBasicOutput` | session-or-api-key | +| `contacts.update` | mutation | `PATCH /contacts/{id}` | `contactUpdateArgs` | `contactBasicOutput` | session-or-api-key | +| `contacts.archive` | mutation | `POST /contacts/{id}/archive` | `contactIdInput` | `contactNameOutput` | session-or-api-key | +| `contacts.restore` | mutation | `POST /contacts/{id}/restore` | `contactIdInput` | `contactNameOutput` | session-or-api-key | +| `contacts.purge` | mutation | `DELETE /contacts/{id}` | `contactIdInput` | `contactNameOutput` | session-or-api-key | +| `contacts.enrich` | mutation | `POST /contacts/{id}/enrich` | `contactIdInput` | `contactEnrichOutput` | session-or-api-key | +| `contacts.bulkAssignOwner` | mutation | `POST /contacts/bulk-assign-owner` | `contactBulkOwnerInput` | `bulkResultOutput` | session-or-api-key | +| `contacts.bulkSetCompany` | mutation | `POST /contacts/bulk-set-company` | `contactBulkCompanyInput` | `bulkResultOutput` | session-or-api-key | +| `contacts.bulkEnrich` | mutation | `POST /contacts/bulk-enrich` | `contactBulkInput` | `bulkResultOutput` | session-or-api-key | +| `contacts.bulkArchive` | mutation | `POST /contacts/bulk-archive` | `contactBulkInput` | `bulkResultOutput` | session-or-api-key | +| `contacts.bulkRestore` | mutation | `POST /contacts/bulk-restore` | `contactBulkInput` | `bulkResultOutput` | session-or-api-key | +| `contacts.bulkPurge` | mutation | `POST /contacts/bulk-purge` | `contactBulkInput` | `bulkResultOutput` | session-or-api-key | +| `contacts.decideFact` | mutation | `POST /contacts/decide-fact` | `factDecisionInput` | `decideFactOutput` | session-or-api-key | + +### `conversations` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `conversations.list` | query | `GET /conversations` | `conversationListInput` | `conversationListOutput` | session-or-api-key | +| `conversations.builderList` | query | `GET /conversations/builder` | `none` | `builderListOutput` | session-or-api-key | +| `conversations.builderResources` | query | `GET /conversations/builder-resources` | `builderResourceSearchInput` | `builderResourcesOutput` | session-or-api-key | +| `conversations.builderById` | query | `GET /conversations/builder/{id}` | `conversationIdInput` | `builderConversationDetailOutput` | session-or-api-key | +| `conversations.events` | query | `GET /conversations/{id}/events` | `conversationEventsInput` | `conversationEventsOutput` | session-or-api-key | +| `conversations.save` | mutation | `POST /conversations` | `conversationSaveInput` | `conversationIdOutput` | session-or-api-key | +| `conversations.createBuilder` | mutation | `POST /conversations/builder` | `builderConversationCreateInput` | `conversationIdOutput` | session-or-api-key | +| `conversations.submitBuilder` | mutation | `POST /conversations/{id}/submit-builder` | `builderConversationSubmitInput` | `conversationIdOutput` | session-or-api-key | +| `conversations.answerBuilderQuestion` | mutation | `POST /conversations/{id}/answer-builder-question` | `builderQuestionResponseInput` | `conversationIdOutput` | session-or-api-key | +| `conversations.rateBuilderResponse` | mutation | `POST /conversations/{id}/rate-builder-response` | `builderResponseRatingInput` | `builderResponseRatingOutput` | session-or-api-key | +| `conversations.markRead` | mutation | `PATCH /conversations/{id}/read` | `conversationIdInput` | `conversationIdOutput` | session-or-api-key | +| `conversations.shareStatus` | query | `GET /conversations/{id}/share` | `conversationIdInput` | `conversationShareStatusOutput` | session-or-api-key | +| `conversations.createShare` | mutation | `POST /conversations/{id}/share` | `conversationIdInput` | `conversationShareTokenOutput` | session-or-api-key | +| `conversations.revokeShare` | mutation | `DELETE /conversations/{id}/share` | `conversationIdInput` | `conversationIdOutput` | session-or-api-key | +| `conversations.shared` | query | `GET /conversations/shared/{token}` | `sharedConversationInput` | `sharedConversationOutput` | session-or-api-key | +| `conversations.remove` | mutation | `DELETE /conversations/{id}` | `conversationIdInput` | `conversationIdOutput` | session-or-api-key | + +### `currency` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `currency.settings` | query | `GET /currency/settings` | `none` | `currencySettingsOutput` | session-or-api-key | +| `currency.setReportingCurrency` | mutation | `PATCH /currency/reporting-currency` | `setReportingCurrencyInput` | `currencySettingsOutput` | session-or-api-key | +| `currency.setManualRate` | mutation | `PUT /currency/rates/{currency}` | `setManualRateInput` | `currencySettingsOutput` | session-or-api-key | +| `currency.removeManualRate` | mutation | `DELETE /currency/rates/{currency}` | `removeManualRateInput` | `currencySettingsOutput` | session-or-api-key | +| `currency.refreshRates` | mutation | `POST /currency/rates/refresh` | `none` | `currencySettingsOutput` | session-or-api-key | + +### `dashboard` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `dashboard.summary` | query | `GET /dashboard/summary` | `dashboardSummaryInput` | `dashboardSummaryOutput` | session-or-api-key | + +### `deals` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `deals.list` | query | `POST /deals/search` | `dealListInput` | `dealListOutput` | session-or-api-key | +| `deals.byId` | query | `GET /deals/{id}` | `dealIdInput` | `dealDetailOutput` | session-or-api-key | +| `deals.create` | mutation | `POST /deals` | `dealCreateInput` | `dealCreateOutput` | session-or-api-key | +| `deals.update` | mutation | `PATCH /deals/{id}` | `dealUpdateArgs` | `dealMutateOutput` | session-or-api-key | +| `deals.archive` | mutation | `POST /deals/{id}/archive` | `dealIdInput` | `dealMutateOutput` | session-or-api-key | +| `deals.restore` | mutation | `POST /deals/{id}/restore` | `dealIdInput` | `dealMutateOutput` | session-or-api-key | +| `deals.purge` | mutation | `DELETE /deals/{id}` | `dealIdInput` | `dealMutateOutput` | session-or-api-key | +| `deals.setStage` | mutation | `PATCH /deals/{id}/stage` | `setStageInput` | `dealSetStageOutput` | session-or-api-key | +| `deals.contactOptions` | query | `GET /deals/{dealId}/contact-options` | `dealContactsInput` | `dealContactOptionsOutput` | session-or-api-key | +| `deals.attachContact` | mutation | `POST /deals/{dealId}/contacts` | `dealAttachContactInput` | `dealContactLinkOutput` | session-or-api-key | +| `deals.detachContact` | mutation | `DELETE /deals/{dealId}/contacts/{contactId}` | `dealDetachContactInput` | `dealContactLinkOutput` | session-or-api-key | +| `deals.setContactRole` | mutation | `PATCH /deals/{dealId}/contacts/{contactId}/role` | `dealContactRoleInput` | `dealContactRoleOutput` | session-or-api-key | +| `deals.bulkAssignOwner` | mutation | `POST /deals/bulk-assign-owner` | `dealBulkOwnerInput` | `dealBulkResultOutput` | session-or-api-key | +| `deals.bulkSetStage` | mutation | `POST /deals/bulk-set-stage` | `dealBulkStageInput` | `dealBulkResultOutput` | session-or-api-key | +| `deals.bulkArchive` | mutation | `POST /deals/bulk-archive` | `dealBulkInput` | `dealBulkResultOutput` | session-or-api-key | +| `deals.bulkRestore` | mutation | `POST /deals/bulk-restore` | `dealBulkInput` | `dealBulkResultOutput` | session-or-api-key | +| `deals.bulkPurge` | mutation | `POST /deals/bulk-purge` | `dealBulkInput` | `dealBulkResultOutput` | session-or-api-key | + +### `enrichment` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `enrichment.queue` | query | `GET /enrichment/queue` | `enrichmentQueueInput` | `enrichmentQueueOutput` | session-or-api-key | + +### `fields` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `fields.list` | query | `GET /fields` | `fieldListInput` | `fieldListOutput` | session-or-api-key | +| `fields.byKey` | query | `GET /fields/{entity}/{key}` | `fieldByKeyInput` | `serializedFieldOutput` | session-or-api-key | +| `fields.filters` | query | `GET /fields/{entity}/filterable` | `fieldEntityInput` | `fieldFiltersOutput` | session-or-api-key | +| `fields.coverage` | query | `GET /fields/{id}/coverage` | `fieldIdInput` | `fieldCoverageOutput` | session-or-api-key | +| `fields.create` | mutation | `POST /fields` | `fieldCreateInput` | `serializedFieldOutput` | session-or-api-key | +| `fields.update` | mutation | `PATCH /fields/{id}` | `fieldUpdateArgs` | `serializedFieldOutput` | session-or-api-key | +| `fields.reorder` | mutation | `POST /fields/reorder` | `fieldReorderInput` | `fieldReorderOutput` | session-or-api-key | +| `fields.archive` | mutation | `POST /fields/{id}/archive` | `fieldIdInput` | `serializedFieldOutput` | session-or-api-key | +| `fields.restore` | mutation | `POST /fields/{id}/restore` | `fieldIdInput` | `serializedFieldOutput` | session-or-api-key | +| `fields.delete` | mutation | `DELETE /fields/{id}` | `fieldIdInput` | `fieldDeleteOutput` | session-or-api-key | +| `fields.backfill` | mutation | `POST /fields/{id}/backfill` | `fieldIdInput` | `fieldBackfillOutput` | session-or-api-key | + +### `google` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `google.status` | query | `GET /google/status` | `none` | `googleConnectionStatusOutput` | session-or-api-key | +| `google.purgeSyncedData` | mutation | `POST /google/purge-synced-data` | `none` | `purgeSyncedDataOutput` | session-or-api-key | +| `google.revokeAccess` | mutation | `POST /google/revoke` | `none` | `revokeAccessOutput` | session-or-api-key | +| `google.syncNow` | mutation | `POST /google/sync` | `none` | `googleConnectionStatusOutput` | session-or-api-key | +| `google.setAutoCreate` | mutation | `PATCH /google/auto-create` | `setAutoCreateInput` | `googleConnectionStatusOutput` | session-or-api-key | +| `google.suppressDomain` | mutation | `POST /google/suppress-domain` | `suppressDomainInput` | `suppressDomainOutput` | session-or-api-key | +| `google.thread` | query | `GET /google/threads/{threadId}` | `threadInput` | `emailThreadOutput` | session-or-api-key | +| `google.event` | query | `GET /google/events/{eventId}` | `calendarEventInput` | `calendarEventOutput` | session-or-api-key | + +### `microsoft` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `microsoft.status` | query | `GET /microsoft/status` | `none` | `microsoftConnectionStatusOutput` | session-or-api-key | +| `microsoft.purgeSyncedData` | mutation | `POST /microsoft/purge-synced-data` | `none` | `purgeSyncedDataOutput` | session-or-api-key | +| `microsoft.revokeAccess` | mutation | `POST /microsoft/revoke` | `none` | `revokeAccessOutput` | session-or-api-key | +| `microsoft.syncNow` | mutation | `POST /microsoft/sync` | `none` | `microsoftConnectionStatusOutput` | session-or-api-key | +| `microsoft.setAutoCreate` | mutation | `PATCH /microsoft/auto-create` | `setOutlookAutoCreateInput` | `microsoftConnectionStatusOutput` | session-or-api-key | + +### `savedViews` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `savedViews.list` | query | `GET /saved-views` | `savedViewListInput` | `savedViewListOutput` | session-or-api-key | +| `savedViews.create` | mutation | `POST /saved-views` | `savedViewCreateInput` | `savedViewOutput` | session-or-api-key | +| `savedViews.update` | mutation | `PATCH /saved-views/{id}` | `savedViewUpdateArgs` | `savedViewOutput` | session-or-api-key | +| `savedViews.delete` | mutation | `DELETE /saved-views/{id}` | `savedViewIdInput` | `savedViewDeleteOutput` | session-or-api-key | + +### `search` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `search.quick` | query | `GET /search` | `quickInput` | `quickOutput` | session-or-api-key | + +### `settings` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `settings.agentModel` | query | `GET /settings/agent-model` | `none` | `agentModelOutput` | session-or-api-key | +| `settings.modelCatalog` | query | `GET /settings/model-catalog` | `none` | `modelCatalogOutput` | session-or-api-key | +| `settings.setAgentModel` | mutation | `PATCH /settings/agent-model` | `setAgentModelInput` | `agentModelOutput` | session-or-api-key | +| `settings.researchKey` | query | `GET /settings/research-key` | `none` | `researchKeyOutput` | session-or-api-key | +| `settings.setResearchKey` | mutation | `PATCH /settings/research-key` | `setResearchKeyInput` | `researchKeyOutput` | session-or-api-key | +| `settings.archiveRetention` | query | `GET /settings/archive-retention` | `none` | `archiveRetentionOutput` | session-or-api-key | +| `settings.setArchiveRetention` | mutation | `PATCH /settings/archive-retention` | `setArchiveRetentionDaysInput` | `archiveRetentionOutput` | session-or-api-key | + +### `slack` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `slack.status` | query | `GET /slack/status` | `none` | `slackStatusOutput` | session-or-api-key | +| `slack.matches` | query | `GET /slack/matches` | `none` | `slackMatchesOutput` | session-or-api-key | +| `slack.channels` | query | `GET /slack/channels` | `slackChannelsInput` | `slackChannelsOutput` | session-or-api-key | +| `slack.joinChannel` | mutation | `POST /slack/channels/{channelId}/join` | `slackJoinChannelInput` | `slackJoinChannelOutput` | session-or-api-key | +| `slack.refreshPeople` | mutation | `POST /slack/people/refresh` | `none` | `slackRefreshPeopleOutput` | session-or-api-key | +| `slack.createChannel` | mutation | `POST /slack/channels` | `slackCreateChannelInput` | `slackCreateChannelOutput` | session-or-api-key | +| `slack.disconnect` | mutation | `DELETE /slack/connection` | `none` | `slackDisconnectOutput` | session-or-api-key | + +### `sso` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `sso.signInOptions` | query | `GET /sso/sign-in-options` | `none` | `ssoSignInOptionsOutput` | public | +| `sso.settings` | query | `GET /sso/settings` | `none` | `ssoSettingsOutput` | session-or-api-key | +| `sso.list` | query | `GET /sso` | `ssoProviderListInput` | `ssoProviderListOutput` | session-or-api-key | +| `sso.register` | mutation | `POST /sso` | `registerSsoProviderInput` | `ssoProviderOutput` | session-or-api-key | +| `sso.remove` | mutation | `DELETE /sso/{providerId}` | `deleteSsoProviderInput` | `deleteSsoProviderOutput` | session-or-api-key | + +### `tracking` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `tracking.settings` | query | `GET /tracking/settings` | `none` | `trackingSettingsOutput` | session-or-api-key | +| `tracking.setFlag` | mutation | `PATCH /tracking/flags` | `trackingFlagInput` | `z.void()` | session-or-api-key | +| `tracking.setCookieLifetime` | mutation | `PATCH /tracking/cookie-lifetime` | `cookieLifetimeInput` | `z.void()` | session-or-api-key | +| `tracking.addDomain` | mutation | `POST /tracking/domains` | `addDomainInput` | `trackedDomainOutput` | session-or-api-key | +| `tracking.removeDomain` | mutation | `DELETE /tracking/domains/{id}` | `removeDomainInput` | `z.void()` | session-or-api-key | +| `tracking.rotateSiteId` | mutation | `POST /tracking/site-id/rotate` | `none` | `rotateSiteIdOutput` | session-or-api-key | +| `tracking.verify` | mutation | `POST /tracking/verify` | `verifyInput` | `verifyOutput` | session-or-api-key | +| `tracking.sources` | query | `GET /tracking/sources` | `none` | `sourcesOutput` | session-or-api-key | +| `tracking.companyActivity` | query | `GET /tracking/companies/{companyId}/activity` | `companyActivityInput` | `websiteActivityOutput` | session-or-api-key | +| `tracking.contactActivity` | query | `GET /tracking/contacts/{contactId}/activity` | `contactActivityInput` | `websiteActivityOutput` | session-or-api-key | + +### `users` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `users.me` | query | Not exposed over REST | `none` | `inferred` | session-or-api-key | +| `users.list` | query | `GET /users` | `none` | `usersListOutput` | session-or-api-key | + +### `workspace` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `workspace.get` | query | `GET /workspace` | `none` | `workspaceOutput` | session-or-api-key | +| `workspace.gate` | query | Not exposed over REST | `none` | `workspaceGate` | session-or-api-key | +| `workspace.members` | query | `POST /workspace/members/search` | `memberListInput` | `memberListOutput` | session-or-api-key | +| `workspace.update` | mutation | `PATCH /workspace` | `updateWorkspaceInput` | `workspaceOutput` | session-or-api-key | +| `workspace.setMemberRole` | mutation | `PATCH /workspace/members/{memberId}/role` | `setMemberRoleInput` | `workspaceMemberOutput` | session-or-api-key | + + +## Evidence, findings, and source paths + +| Evidence | Finding | Source path | +| --- | --- | --- | +| tRPC router AST contains 173 decorated procedures | The application exposes 173 tRPC procedures | `apps/api/src/**/*.router.ts` | +| Generated router contains 173 procedure definitions | Generated client types match the router count | `apps/api/src/generated/server.ts` | +| 159 procedures use `restMeta`, 7 asset procedures, and 5 appointment procedures carry REST metadata | The REST bridge exposes 171 root-path operations | `apps/api/src/**/*.router.ts`, `apps/api/src/assets/asset-openapi.ts`, `apps/api/src/appointments/appointment-openapi.ts` | +| Merged runtime OpenAPI contains 156 paths | The document includes native controller paths and 171 REST operations | `/openapi.json` | +| Better Auth installs session, OAuth, SSO, and API-key plugins | Better Auth mounts a larger protocol surface | `packages/auth/src/auth.ts` | +| `AuthMiddleware` requires a request principal | Protected tRPC routes reject anonymous access | `apps/api/src/trpc/middlewares/auth.middleware.ts` | +| `SessionOnlyMiddleware` requires a session principal | API-key management requires browser sessions | `apps/api/src/trpc/middlewares/session-only.middleware.ts` | +| Role helpers accept owner and admin | Management permissions share one role boundary | `packages/auth/src/organization.ts` | +| Shared conversation routes use `AuthMiddleware` | Share tokens do not create anonymous access | `apps/api/src/conversations/conversations.router.ts` | +| Native attachment controller requires `@Principal` | Attachments accept every supported user credential | `apps/api/src/conversations/conversation-attachments.controller.ts` | +| Better Auth configures `jwt` and `oauthProvider` | CRM issues OAuth and OIDC tokens | `packages/auth/src/auth.ts` | +| tRPC context resolves one request principal | REST bearer tokens share the authentication boundary | `apps/api/src/trpc/trpc.context.ts` | +| OAuth scope middleware checks procedure type | Queries and mutations require distinct CRM scopes | `apps/api/src/trpc/middlewares/oauth-scope.middleware.ts` | +| OpenAPI declares three credential alternatives | Generated clients receive bearer configuration | `apps/api/src/create-app.ts` | +| Better Auth runtime packages use 1.7.2 | Runtime auth dependencies share one version | `packages/auth/package.json` | + +## Maintenance checklist + +Run tRPC generation after adding or changing a procedure. + +Commit `apps/api/src/generated/server.ts` with router changes. + +Keep REST metadata on every supported REST bridge procedure. + +Set `protect: false` only for intentionally public procedures. + +Apply `AuthMiddleware` at the router or procedure boundary. + +Add service-level permission checks for restricted writes. + +Keep API-key management behind `SessionOnlyMiddleware`. + +Update this inventory when controllers, routers, or Better Auth plugins change. + +Verify `/openapi.json` after starting the current API process. + +Keep Better Auth runtime packages on one tested version. + +Register OAuth clients administratively and keep dynamic registration disabled. + +Verify issuer, audience, scope, PKCE, refresh rotation, and revocation before mobile release. diff --git a/docs/kaneo-integration.md b/docs/kaneo-integration.md new file mode 100644 index 000000000..8774a09a7 --- /dev/null +++ b/docs/kaneo-integration.md @@ -0,0 +1,117 @@ +# Kaneo integration + +How Kaneo's project management is bundled into the CRM: one database, kaneo's own +controllers and web UI served as-is, one session, and the agent calling kaneo's +controller functions directly. + +Read `vendor/FORK-DELTA.md` for the fork changes that make this work, and +`docs/api.md` for the CRM's own rules. The strategy decision is in +`adrs/kaneo.md`. + +## The kaneo source + +Kaneo lives at `vendor/kaneo` as a git submodule pointing at the +`crm-integration` branch of the `romanbsd/kaneo` fork. The CRM-specific +deltas (column names, renames, cookie prefix, migration gate) are committed +in that branch, not copied into this repository. Updating kaneo means +rebasing the fork branch on upstream and bumping the submodule pointer — see +`vendor/FORK-DELTA.md`. + +A fresh checkout must initialize the submodule and install kaneo's +dependencies before `dev:kaneo` runs: + +```sh +git submodule update --init vendor/kaneo +cd vendor/kaneo && bun install +``` + +## The one database + +Kaneo and the CRM share one Postgres schema, owned by Prisma in +`packages/db/prisma/schema.prisma`. Prisma runs every migration; kaneo's own +migrator is disabled (`KANEO_SKIP_DRIZZLE_MIGRATIONS`, fork delta). + +- The 32 kaneo tables are generated from the abstract model in + `packages/kaneo-domain` (`bun run generate:prisma` regenerates + `kaneo.prisma`, appended into `schema.prisma`). The Drizzle binding and the + parity test in that package prove the generated schema matches what kaneo's + code expects. +- **Kaneo tables keep snake_case physical columns** (`project_id`, `created_at`), + matching kaneo's Drizzle schema. The Prisma fragment emits a column `@map` + for each. +- **The shared auth tables keep the CRM's camelCase physical columns** + (`emailVerified`, `createdAt`). Kaneo's `schema.ts` is patched to read those + names, so both ORMs see the same rows. +- Two physical renames avoid collisions with the CRM's live tables: + `activity` → `task_activity`, `invitation` → `workspace_invitation`. +- Migrations: `kaneo_domain` (the 32 tables), `kaneo_auth_columns` (kaneo's + nullable user/session columns), `kaneo_comment_user_nullable` (agent-authored + comments without a user). + +## The mount + +`bun run dev:kaneo` serves the whole stack: + +1. Builds the `@kaneo/*` workspace packages if their `dist` is missing. +2. Boots kaneo's own Hono API (`vendor/kaneo/apps/api`) against the shared + database, with the migration gate on. +3. Serves kaneo's built web SPA (`vendor/kaneo/apps/web/dist`) at root, + proxies `/api/*` to the API, and bridges `/ws` websockets. + +`tools/kaneo-dev.ts` is the dev server. Production hosting is not wired yet: +kaneo's websockets and scheduler need a long-lived process, so Vercel serverless +cannot host the API; the API runs as its own service. + +## Authentication + +One session cookie, one identity. + +- Both apps run Better Auth over the same `user`/`session`/`account`/ + `verification` tables and the same secret (`BETTER_AUTH_SECRET` mapped to + kaneo's `AUTH_SECRET`). +- Kaneo's Better Auth uses `cookiePrefix: "crm"` (fork delta), so + `crm.session_token` is valid at both apps. Both use the same cookie cache + format. +- The CRM owns sign-in. Its `ensureWorkspaceMembership` hook (packages/auth) + also mirrors the single workspace and each signing-in user into kaneo's + `workspace`/`workspace_member` tables, mapping CRM roles onto kaneo's + (`owner`/`admin` → `admin`, `member` → `member`). The sync degrades + independently: a kaneo-table failure never blocks sign-in. +- Kaneo's boot seeds its default `workspace_role` rows (viewer/member/admin) + against the shared workspace. + +## The agent surface + +The eve agent reads projects and tasks through Prisma and writes through +kaneo's own controller functions. + +- **Reads (native Prisma, free):** `project_list`, `task_list`, `task_read` + (`apps/agent/agent/lib/kaneo.ts`). No kaneo runtime needed for reads. +- **Writes (kaneo's controllers, direct):** `task_create`, `task_update`, + `task_comment` call kaneo's extracted controller functions in-process + (`apps/agent/agent/lib/kaneo-writes.ts`), with no HTTP, no MCP, no Hono + context. This reuses kaneo's exact behavior: status validation against the + project's columns, atomic task numbering, assignable-user checks, and + comment rows in the activity feed (`task_activity`, type `comment`) that + kaneo's UI actually renders. +- **The principal:** a dispatched agent has no user, but kaneo's controllers + require `currentUserId`. The agent acts as the workspace owner (the first + `owner` member of the workspace). Decide once; this is the whole permission + model for agent writes. + +Why not MCP: kaneo's MCP server is a thin HTTP client over kaneo's REST API, +and it is user-OAuth-scoped. Eve can consume MCP natively, but the agent has no +user principal for dispatched runs, and an HTTP hop adds a runtime dependency +for the same tables. The direct-controller path removes the transport entirely. + +## Open decisions + +- **Production hosting:** kaneo's API needs a long-lived process (websockets, + scheduler). Not wired. +- **`/api/auth` co-serving:** both apps serve auth at `/api/auth`; fine on + separate ports in dev, one must move when co-served under one origin. +- **Inline routes:** a minority of kaneo's routes are inline Hono handlers, not + extracted controllers; those stay HTTP-only. +- **Activity events:** kaneo's controllers publish events; the agent process + registers no event listeners, so event-driven side effects (notifications) + do not fire for agent writes. \ No newline at end of file diff --git a/docs/oauth-oidc-crm-implementation.md b/docs/oauth-oidc-crm-implementation.md new file mode 100644 index 000000000..a3ccb8018 --- /dev/null +++ b/docs/oauth-oidc-crm-implementation.md @@ -0,0 +1,1188 @@ +# CRM OAuth 2.1 and OpenID Connect Changes + +This document specifies CRM changes for secure Flutter access. + +It preserves browser sessions and API keys. + +It adds standards-based OAuth 2.1 and OpenID Connect support. + +The CRM remains a single-tenant system. + +> [!IMPORTANT] +> The CRM implementation in this document is complete. +> The Flutter client work remains a separate application change. + +## Implementation status + +Implemented on `feat/oauth`: + +- Better Auth runtime packages use version 1.7.2. +- Better Auth CLI package `auth` uses version 1.7.2. +- OAuth Provider and JWT plugins issue CompCRM tokens. +- Prisma migration `20260829113915_add_oauth_provider` adds OAuth storage. +- Startup reconciles the official `compcrm-flutter` public client. +- One request-principal resolver accepts cookies, API keys, and bearer tokens. +- tRPC queries require `crm.read` for OAuth callers. +- tRPC mutations require `crm.write` for OAuth callers. +- Native protected controllers use the same principal and scope guard. +- OAuth, OpenID Connect, protected-resource, and JWKS metadata are public. +- OpenAPI describes cookie, API-key, and bearer alternatives. +- The web application preserves signed authorization state through sign-in. +- The web application provides a custom-client consent page. + +## 1. Executive decision + +CompCRM must become an OAuth 2.1 authorization server for first-party mobile clients. + +The server must also expose OpenID Connect discovery and identity tokens. + +The Flutter application must use Authorization Code Flow with PKCE. + +The CRM API must accept short-lived OAuth access tokens. + +The browser application must continue to use Better Auth session cookies. + +Existing integrations must continue to use API keys. + +The three credential types must resolve into one request principal. + +Current role checks must remain the final authorization boundary. + +OAuth scopes must restrict each client before role checks run. + +The implementation must not accept access tokens from upstream identity providers. + +The implementation must not add tenant headers or organization parameters. + +### 1.1 Recommendation summary + +| Decision | Recommendation | +| --- | --- | +| Authorization server | Use the Better Auth OAuth Provider plugin. | +| Identity protocol | Enable OpenID Connect through the `openid` scope. | +| Mobile flow | Use Authorization Code Flow with PKCE and a system browser. | +| Mobile client | Register one public native client for the official Flutter application. | +| Client secret | Do not issue a secret to the Flutter application. | +| Access tokens | Use signed JWT access tokens with a ten-minute lifetime. | +| Refresh tokens | Rotate refresh tokens and expire them after 30 days. | +| Refresh retry | Allow a 30-second reuse interval for lost mobile responses. | +| API authorization | Require OAuth scopes and current CRM roles. | +| Browser access | Keep existing session-cookie behavior. | +| Automation access | Keep existing API-key behavior. | +| Multi-tenancy | Do not add tenant selection or tenant claims. | +| Dynamic registration | Keep dynamic client registration disabled. | +| Token exchange | Do not add token exchange in the first release. | +| Client credentials | Do not add machine OAuth grants in the first release. | +| Proof of possession | Do not add DPoP in the first release. | + +## 2. Starting state + +CompCRM currently uses Better Auth as an authentication client and session manager. + +The API mounts Better Auth routes under `/api/auth/*`. + +The web application authenticates with Better Auth cookies. + +The API also accepts Better Auth API keys. + +The API does not accept OAuth bearer access tokens. + +The API does not publish OAuth authorization-server metadata. + +The database does not contain OAuth client, consent, token, or signing-key tables. + +The OpenAPI documents contain cookie and API-key schemes only. + +The tRPC context loads a Better Auth session from request headers. + +The current authorization middleware requires a session user. + +The current SSO feature connects CompCRM to an external OIDC provider. + +That feature makes CompCRM an OIDC client. + +It does not make CompCRM an authorization server. + +### 2.1 Existing authentication paths + +| Caller | Credential | Authentication path | Current result | +| --- | --- | --- | --- | +| Web browser | Session cookie | Better Auth session lookup | Supported | +| Integration | `x-api-key` | Better Auth API-key session | Supported | +| Flutter application | Bearer access token | No verifier exists | Not supported | +| External OIDC provider | Authorization response | Better Auth SSO | Supported for web sign-in | + +### 2.2 Existing authorization model + +CompCRM uses one fixed workspace. + +`WORKSPACE_ID` identifies this workspace. + +Sign-in adds the user to this workspace. + +Workspace membership uses the existing organization records. + +Owner, administrator, and member roles control CRM actions. + +Service procedures enforce those permissions. + +The OAuth implementation must reuse these checks. + +### 2.3 Verified source findings + +| Evidence | Finding | Implementation path | +| --- | --- | --- | +| `packages/auth/src/auth.ts` | Better Auth has SSO, organization, API-key, and generic OAuth plugins. | Add OAuth Provider and JWT plugins here. | +| `apps/api/src/trpc/trpc.context.ts` | The context only requests a Better Auth session. | Resolve one typed request principal here. | +| `apps/api/src/trpc/middlewares/auth.middleware.ts` | Protected procedures require `ctx.session.user`. | Require `ctx.principal.user` instead. | +| `apps/api/src/trpc/middlewares/session-only.middleware.ts` | Session-only access checks the API-key header. | Check the resolved credential kind instead. | +| `apps/api/src/create-app.ts` | OpenAPI defines cookie and API-key security. | Add an OAuth bearer scheme. | +| `apps/app/proxy.ts` | The proxy gates routes with a session cookie. | Permit authenticated OAuth consent routes. | +| `packages/db/prisma/schema.prisma` | OAuth Provider tables were absent. | The migration adds the generated models. | +| `packages/auth/package.json` | Runtime packages used version 1.6.25. | Runtime packages now use version 1.7.2. | + +## 3. Target architecture + +The CRM owns authorization, token issuance, and token verification. + +An upstream SSO provider still owns optional workforce authentication. + +The Flutter client never handles the upstream provider token directly. + +The Flutter client receives only CompCRM tokens. + +```mermaid +flowchart LR + Flutter[Flutter application] -->|Authorization Code and PKCE| Auth[CompCRM authorization server] + Browser[Web browser] -->|Session cookie| API[CompCRM API] + Integration[Integration] -->|API key| API + Auth -->|Login redirect| SSO[Optional upstream OIDC provider] + SSO -->|CompCRM session| Auth + Auth -->|ID token and access token| Flutter + Flutter -->|Bearer access token| API + API --> Principal[Request principal resolver] + Principal --> Scope[OAuth scope check] + Scope --> Role[Current workspace role check] + Role --> Service[Existing CRM services] +``` + +### 3.1 Trust boundaries + +The Flutter application is a public client. + +It cannot protect a client secret. + +PKCE protects the authorization code. + +The system browser protects the user authentication session. + +The API validates every bearer token locally or through the provider verifier. + +The API never trusts mobile claims without signature validation. + +The database protects refresh tokens, client records, consent records, and signing keys. + +### 3.2 Protocol endpoints + +Better Auth must provide the applicable OAuth and OpenID Connect endpoints. + +The final paths depend on the mounted Better Auth base path. + +Integration tests must verify the public paths before release. + +The public surface must include these capabilities: + +| Capability | Standard endpoint purpose | +| --- | --- | +| Authorization | Starts user authorization and returns a code. | +| Token | Exchanges codes and refresh tokens. | +| User information | Returns identity claims for valid access tokens. | +| Revocation | Revokes supported tokens. | +| Introspection | Reports token state for authorized callers. | +| End session | Ends the related login session. | +| Authorization metadata | Publishes OAuth server capabilities. | +| OpenID metadata | Publishes OIDC issuer and endpoint metadata. | +| JWKS | Publishes public signing keys. | + +The issuer must use the public `API_URL` origin. + +The login and consent pages must use the public `APP_URL` origin. + +Discovery documents must report the exact public endpoints. + +Proxy or load-balancer rewrites must not change the reported issuer. + +## 4. Required CRM changes + +### 4.1 Align authentication dependencies + +Align `better-auth` and every runtime plugin first. + +Use version 1.7.2 for all runtime packages. + +Use CLI version 1.4.22. + +The CLI uses an independent release sequence. + +Commit the package-lock changes with the implementation. + +Run existing authentication tests before schema generation. + +Run them again after dependency alignment. + +Do not combine a version upgrade with unrelated authentication refactoring. + +### 4.2 Add the OAuth Provider plugin + +Add `@better-auth/oauth-provider` to `packages/auth`. + +Add the Better Auth JWT plugin from the aligned release. + +Keep existing SSO, organization, API-key, and generic OAuth plugins. + +Disable the standalone JWT `/token` endpoint. + +Disable JWT headers on normal session responses. + +Only the OAuth Provider must issue API access tokens. + +Create `packages/auth/src/oauth-config.ts` for tunable OAuth values. + +Group every duration and scope in one exported constant. + +Do not place token durations beside individual consumers. + +The configuration should contain these values: + +```ts +const MINUTE_SECONDS = 60; +const DAY_SECONDS = 24 * 60 * MINUTE_SECONDS; + +export const OAUTH = { + accessTokenTtlSeconds: 10 * MINUTE_SECONDS, + authorizationCodeTtlSeconds: 10 * MINUTE_SECONDS, + refreshTokenTtlSeconds: 30 * DAY_SECONDS, + refreshTokenReuseIntervalSeconds: 30, + scopes: { + identity: ["openid", "profile", "email", "offline_access"], + crm: ["crm.read", "crm.write"], + }, + resource: new URL("/api", apiUrl).toString(), +} as const; +``` + +Do not duplicate these values in the API or Flutter application. + +### 4.3 Register the official Flutter client + +Register one public native OAuth client. + +Use a stable identifier such as `compcrm-flutter`. + +Do not assign a client secret. + +Set the token endpoint authentication method to `none`. + +Permit only authorization-code and refresh-token grants. + +Permit only the code response type. + +Require PKCE with `S256`. + +Use exact redirect URIs. + +Reject wildcard redirect URIs. + +Use an application-owned HTTPS link where platform support is complete. + +Use a reverse-domain private scheme only as a controlled fallback. + +Use loopback redirects only for desktop development. + +Enable `skipConsent` only for the bundled first-party client. + +Keep consent for every custom client. + +Do not enable dynamic client registration. + +Create an idempotent client reconciliation command. + +The command must create or update only the official client record. + +The command must reject unsafe redirect URI changes. + +Custom self-hosted clients require an administrator registration command. + +Reconcile the bundled client from the repository root: + +```bash +bun run --filter=@crm/auth oauth:reconcile-client +``` + +Register a custom public native client from the repository root: + +```bash +bun run --filter=@crm/auth oauth:register-client --client-id example-native --name "Example Native" --redirect-uri com.example.app:/oauth/callback --post-logout-redirect-uri com.example.app:/oauth/logout +``` + +Repeat `--redirect-uri` or `--post-logout-redirect-uri` for each exact address. + +The registration command rejects duplicates, fragments, wildcards, and unsafe transport schemes. + +### 4.4 Generate the database schema + +Run the Better Auth CLI against the final plugin configuration. + +Inspect the generated Prisma changes before creating a migration. + +Expected records include OAuth clients, consents, access tokens, and refresh tokens. + +Expected records also include client assertions and signing keys. + +Exact model names depend on the aligned Better Auth release. + +The CLI parser rejects the existing valid Prisma filtered indexes. + +Generate the OAuth schema into an isolated Prisma file. + +Merge the exact generated models into the repository schema. + +Create one reviewed Prisma migration. + +Run the migration against a disposable test database first. + +The test database name must end with `_test`. + +Verify migration rollback behavior through a database snapshot. + +Do not delete OAuth tables during an application rollback. + +### 4.5 Create a unified request principal + +Create a domain type at the authentication boundary. + +Do not pass untyped authentication data through the API. + +The type should represent these fields: + +```ts +type CredentialKind = "session" | "apiKey" | "oauth"; + +type RequestPrincipal = { + credentialKind: CredentialKind; + user: SessionUser; + clientId: string | null; + scopes: ReadonlySet; +}; +``` + +Derive the real type from validated provider output where available. + +Do not trust arbitrary token claim objects. + +Create a request-principal service in `apps/api/src/auth`. + +The service must inspect the request once. + +It must reject requests containing multiple credential types. + +This rule prevents credential confusion attacks. + +The resolver must use this order: + +1. Detect cookie, API-key, and bearer credentials. +2. Reject an ambiguous request with HTTP 400. +3. Verify OAuth bearer credentials with the provider verifier. +4. Resolve session and API-key credentials through Better Auth. +5. Return one typed principal. +6. Return no principal for an anonymous request. + +The bearer verifier must validate these properties: + +| Property | Required validation | +| --- | --- | +| Signature | A current trusted JWKS key signs the token. | +| Issuer | The issuer exactly matches the public CompCRM issuer. | +| Audience | The audience includes the CompCRM API resource. | +| Expiry | The current time precedes `exp`. | +| Activation | The current time follows `nbf`, when present. | +| Subject | The subject maps to a current CompCRM user. | +| Client | The client identifier names an enabled OAuth client. | +| Scope | The token contains every required OAuth scope. | + +Reject malformed bearer values with HTTP 401. + +Reject expired or invalid tokens with HTTP 401. + +Reject insufficient scopes with HTTP 403. + +Return a standards-compatible `WWW-Authenticate` header. + +Never convert a verification failure into an anonymous request silently. + +### 4.6 Update tRPC context and middleware + +Add `principal` to `BaseTrpcContext`. + +Keep `session` temporarily when existing call sites still need it. + +Remove the duplicate session field after migration. + +Change `AuthMiddleware` to require `ctx.principal.user`. + +Build `AuthedTrpcContext` from the typed principal. + +Change session-only checks to inspect `credentialKind`. + +Do not inspect raw credential headers in downstream middleware. + +The API-key management router must accept browser sessions only. + +OAuth tokens must not create, list, or revoke API keys. + +API keys must not manage other API keys. + +### 4.7 Enforce OAuth scopes + +Define the first CRM resource scopes as follows: + +| Scope | Meaning | +| --- | --- | +| `openid` | Request an OpenID Connect identity token. | +| `profile` | Request standard profile claims. | +| `email` | Request standard email claims. | +| `offline_access` | Request refresh-token access. | +| `crm.read` | Read CRM resources. | +| `crm.write` | Create, update, or delete CRM resources. | + +OAuth scopes restrict the client. + +Workspace roles restrict the user. + +Both checks must pass. + +Do not place owner or administrator status in durable access-token claims. + +Role changes must take effect without waiting for token expiry. + +Existing service authorization must load current membership and role data. + +Use `crm.read` for tRPC queries. + +Use `crm.write` for tRPC mutations. + +Apply equivalent policies to native REST controllers. + +Public procedures must remain public. + +Session and API-key behavior must remain unchanged during the first release. + +Add finer scopes only after a real client needs them. + +Avoid entity-specific scopes during the first release. + +### 4.8 Update Nest controller authentication + +The Better Auth Nest integration currently protects controller routes. + +Bearer support must use the same request-principal resolver. + +Create one shared guard or decorator for authenticated controllers. + +Do not create a second authorization policy for controllers. + +Public controllers must use an explicit public marker. + +Protected controllers must require the unified principal. + +Controller scope failures must match tRPC failures. + +### 4.9 Add OAuth security to OpenAPI + +Add an HTTP bearer scheme with JWT format. + +Keep the cookie scheme. + +Keep the API-key scheme. + +Describe protected operations with alternative security requirements. + +The alternatives must mean cookie OR API key OR bearer token. + +They must not mean all three credentials together. + +Add the bearer scheme to the REST bridge document. + +Keep public operations without security requirements. + +Document `crm.read` and `crm.write` for OAuth clients. + +Regenerate any committed client artifacts after the document changes. + +### 4.10 Add login and consent routing + +Reuse the existing `/sign-in` page for user authentication. + +Add an OAuth consent page for custom clients. + +Place the page under the existing landing route group. + +Add the OAuth route prefix to the proxy ungated list. + +Anonymous users must still redirect to `/sign-in`. + +Authenticated users must bypass onboarding gates during authorization. + +The server page must load client, scope, and consent data. + +The client component must render finished plain data. + +The client component must not import `@crm/auth` or `@crm/db`. + +Shared controls must come from `packages/ui`. + +The page must show these values: + +- Application name. +- Requested CRM permissions. +- Signed-in account. +- Approve action. +- Deny action. + +The official client normally skips this page. + +The page remains necessary for custom clients. + +### 4.11 Verify discovery routing + +Better Auth is mounted under `/api/auth/*`. + +OAuth discovery uses standard well-known locations. + +The Nest adapter and proxy must expose the locations correctly. + +Add end-to-end tests for authorization metadata. + +Add end-to-end tests for OpenID configuration. + +Add an explicit Nest route adapter when automatic routing fails. + +Do not ship a discovery document with unreachable endpoints. + +### 4.12 Add bounded authentication logs + +Log the authentication method and result. + +Log a bounded OAuth error code. + +Log the client identifier after validation. + +Log the request identifier and user identifier. + +Never log access tokens. + +Never log refresh tokens. + +Never log authorization codes. + +Never log request headers, bodies, or query strings. + +Do not add product telemetry for this change. + +Use operational logs and security metrics only. + +## 5. Flutter integration contract + +The Flutter application must use a system authentication browser. + +Embedded web views must not handle user authentication. + +Use `flutter_appauth` for discovery, PKCE, authorization, and refresh. + +Use `flutter_secure_storage` for refresh-token storage. + +Keep access tokens in memory when practical. + +Never store a client secret in the application. + +### 5.1 Mobile authorization sequence + +```mermaid +sequenceDiagram + participant App as Flutter application + participant Browser as System browser + participant Auth as CompCRM authorization server + participant API as CompCRM API + + App->>App: Create verifier, challenge, state, and nonce + App->>Browser: Open authorization request + Browser->>Auth: Send authorization request and challenge + Auth->>Browser: Authenticate the user + Auth->>Browser: Approve the trusted client + Auth->>Browser: Redirect with code and state + Browser->>App: Deliver redirect URI + App->>App: Validate state + App->>Auth: Exchange code and verifier + Auth->>App: Return ID, access, and refresh tokens + App->>App: Validate ID token nonce and claims + App->>API: Send bearer access token + API->>App: Return CRM data +``` + +### 5.2 Authorization request + +The client must request these scopes: + +```text +openid profile email offline_access crm.read crm.write +``` + +The client must send these values: + +- Exact registered client identifier. +- Exact registered redirect URI. +- Exact `${API_URL}/api` resource in authorization and token requests. +- Response type `code`. +- PKCE challenge method `S256`. +- Cryptographically random state. +- Cryptographically random nonce. + +The client must validate returned state before code exchange. + +The client must validate the ID token nonce. + +The client must validate issuer, audience, signature, and expiry. + +### 5.3 Token storage + +Store the refresh token in platform secure storage. + +Store the current access token in process memory. + +Store token expiry beside the access token. + +Do not store tokens in shared preferences. + +Do not print tokens during development. + +Do not send tokens to crash reporting systems. + +Delete all tokens after logout or unrecoverable refresh failure. + +Use the strictest available platform storage configuration. + +### 5.4 Refresh behavior + +Refresh shortly before access-token expiry. + +Allow only one refresh operation at a time. + +Queue concurrent API requests behind that operation. + +Replace the stored refresh token after every successful refresh. + +Retry one lost refresh response within the reuse interval. + +Stop retrying after `invalid_grant`. + +Clear local credentials after terminal refresh failure. + +Return the user to sign-in. + +Do not loop refresh attempts. + +### 5.5 API client behavior + +Send the access token in the `Authorization` header. + +Use the `Bearer` scheme. + +Never place tokens in query parameters. + +Use the tRPC client for full CRM feature coverage. + +Use generated REST clients only for the documented REST bridge. + +The OpenAPI schema cannot describe every tRPC procedure. + +Regenerate REST models from the public `/openapi.json` document. + +Prefer the OpenAPI Generator `dart-dio` target for REST clients. + +Keep authentication and retry behavior in one Dio interceptor. + +Do not retry mutations after uncertain transport failures automatically. + +### 5.6 Logout behavior + +Revoke the refresh token when supported. + +Call the end-session endpoint when the user requests full logout. + +Delete local tokens even when remote revocation fails. + +Close the local authenticated application state. + +Do not treat local deletion as server revocation. + +## 6. Security requirements + +### 6.1 Token lifetime and revocation + +Use a ten-minute access-token lifetime. + +Use a 30-day refresh-token lifetime. + +Rotate refresh tokens on every use. + +Use a 30-second refresh reuse interval. + +Revoke the refresh chain after detected reuse outside that interval. + +JWT access tokens remain valid until expiry. + +Session revocation must stop future refresh operations. + +Client disablement must stop authorization and refresh operations. + +Signing-key rotation must preserve active public keys during overlap. + +### 6.2 Redirect security + +Match redirect URIs exactly. + +Require HTTPS for claimed web redirects. + +Allow loopback HTTP only for local native clients. + +Reject fragments in registered redirect URIs. + +Reject wildcard hosts and paths. + +Review private scheme ownership for Android and iOS. + +Use universal links or app links where practical. + +### 6.3 Request security + +Reject multiple credential types. + +Reject bearer tokens on session-only routes. + +Reject missing required resource audiences. + +Reject missing required scopes. + +Apply rate limits to authorization, token, refresh, and revocation endpoints. + +Keep Better Auth database rate limiting enabled. + +Use generic public error messages. + +Log bounded internal reason codes. + +### 6.4 Authorization security + +Never authorize from an ID token. + +Never authorize from email claims alone. + +Map the access-token subject to the current user. + +Load current workspace membership before sensitive actions. + +Load current role state before sensitive actions. + +Keep service-level ownership and permission checks. + +Scopes must never grant a role the user lacks. + +### 6.5 Key protection + +Protect the OAuth signing-key database records. + +Restrict production database access. + +Protect `BETTER_AUTH_SECRET` through the existing secret process. + +Never export private JWKS values to application logs. + +Back up signing keys with the database. + +Define a documented emergency rotation process. + +## 7. Database and deployment plan + +### 7.1 Migration order + +1. Align Better Auth package versions. +2. Add provider and JWT plugin configuration. +3. Generate the Prisma schema. +4. Review every generated model and index. +5. Create the database migration. +6. Run migration tests against a `_test` database. +7. Deploy database changes before API changes. +8. Deploy authorization endpoints and bearer verification. +9. Reconcile the official Flutter client. +10. Release the Flutter application. + +### 7.2 Compatibility deployment + +The first API deployment must retain cookies and API keys. + +The new bearer path must be additive. + +Existing tRPC callers must continue without changes. + +Existing integration keys must continue without changes. + +Existing browser sessions must survive the deployment. + +The Flutter client must launch after discovery tests pass. + +### 7.3 Rollback + +Disable the official OAuth client first. + +Stop new authorization and refresh operations. + +Keep the OAuth database tables. + +Keep signing keys until every issued token expires. + +Roll back the API implementation after token expiry. + +Continue browser and API-key access throughout rollback. + +Do not drop OAuth tables during an emergency rollback. + +## 8. File change map + +| Path | Implemented change | +| --- | --- | +| `packages/auth/package.json` | Align Better Auth versions and add OAuth Provider. | +| `packages/auth/src/auth.ts` | Configure OAuth Provider and JWT plugins. | +| `packages/auth/src/oauth-config.ts` | Define resources, scopes, durations, and client policy. | +| `packages/auth/src/env.ts` | No change. Existing public URLs provide every value. | +| `packages/db/prisma/schema.prisma` | Add generated OAuth Provider and JWKS models. | +| `packages/db/prisma/migrations/_add_oauth_provider` | Add the reviewed database migration. | +| `apps/api/src/auth/request-principal.ts` | Define the validated principal type. | +| `apps/api/src/auth/request-principal.service.ts` | Resolve session, API-key, or OAuth credentials. | +| `apps/api/src/trpc/context.types.ts` | Add the principal to API contexts. | +| `apps/api/src/trpc/trpc.context.ts` | Resolve the principal once per request. | +| `apps/api/src/trpc/middlewares/auth.middleware.ts` | Authenticate through the principal. | +| `apps/api/src/trpc/middlewares/session-only.middleware.ts` | Require the session credential kind. | +| `apps/api/src/trpc/middlewares/oauth-scope.middleware.ts` | Enforce OAuth query and mutation scopes. | +| `apps/api/src/trpc/trpc.module.ts` | Register shared authentication and scope middleware. | +| `apps/api/src/create-app.ts` | Add bearer security to both OpenAPI documents. | +| `apps/api/src/app.module.ts` | Register any required auth services and route adapters. | +| `apps/app/proxy.ts` | Permit authenticated OAuth flow pages. | +| `apps/app/app/(landing)/oauth/consent/page.tsx` | Render the server-owned consent page. | +| `packages/ui` | No change. Existing buttons and spinners render consent actions. | +| `apps/api/test/auth.e2e.spec.ts` | Cover discovery, PKCE issuance, refresh, validation, and compatibility. | +| `apps/api/test/oauth-openapi.e2e.spec.ts` | Cover security schemes and OR alternatives. | +| `.env.example` | No change. OAuth requires no new environment value. | +| `apps/api/src/config/env.validation.ts` | No change. OAuth requires no new environment value. | +| `turbo.json` | No change. OAuth requires no new environment value. | +| `docs/api.md` | Document the final authentication architecture. | +| `docs/environment.md` | Document final environment decisions. | +| `docs/exposed-api.md` | Link the implemented bearer flow and scopes. | + +No new environment value is required for the recommended first-party client. + +Use `API_URL` for issuer and API resource origins. + +Use `APP_URL` for login and consent page origins. + +Add no per-package `.env` file. + +## 9. Verification plan + +### 9.1 Unit tests + +Test request-principal resolution for each credential type. + +Test anonymous requests. + +Test duplicate credential rejection. + +Test malformed bearer headers. + +Test invalid signatures. + +Test incorrect issuers. + +Test incorrect audiences. + +Test expired tokens. + +Test future `nbf` values. + +Test disabled clients. + +Test deleted users. + +Test missing read scopes. + +Test missing write scopes. + +Test standards-compatible authentication errors. + +### 9.2 OAuth end-to-end tests + +Test authorization-server metadata. + +Test OpenID configuration metadata. + +Test JWKS publication. + +Test authorization with PKCE `S256`. + +Test rejection without PKCE. + +Test rejection for a wrong verifier. + +Test rejection for a wrong redirect URI. + +Test authorization-code reuse rejection. + +Test ID token issuer, audience, nonce, and expiry. + +Test read access with `crm.read`. + +Test mutation rejection without `crm.write`. + +Test mutation access with `crm.write`. + +Test refresh rotation. + +Test bounded refresh-response reuse. + +Test refresh reuse detection outside the interval. + +Test token revocation. + +Test end-session behavior. + +Test client disablement. + +### 9.3 Compatibility tests + +Run every existing authentication test. + +Confirm browser session authentication. + +Confirm API-key authentication. + +Confirm session-only API-key management. + +Confirm public sign-in options. + +Confirm upstream SSO sign-in. + +Confirm fixed workspace membership. + +Confirm member, administrator, and owner permissions. + +Confirm public tRPC procedures remain public. + +Confirm native controller protection. + +### 9.4 Documentation tests + +Validate both OpenAPI documents. + +Confirm each security requirement uses OR alternatives. + +Generate the Dart REST client. + +Compile the generated Dart client. + +Compare documented discovery endpoints with live responses. + +Confirm every published endpoint is reachable externally. + +### 9.5 Required commands + +Use repository scripts when available. + +Run these checks from the repository root: + +```bash +bun run test +bun run lint +bun run build +git diff --check +``` + +Run the focused API authentication suite before the full suite. + +Use the repository database commands from `docs/setup.md`. + +Do not run production migrations from a development checkout. + +## 10. Acceptance criteria + +The change is complete only after every criterion passes. + +- The official Flutter client uses Authorization Code Flow with PKCE. +- The Flutter package contains no client secret. +- The authorization server publishes valid OAuth metadata. +- The authorization server publishes valid OIDC metadata. +- The server publishes a valid JWKS document. +- The API accepts valid CompCRM bearer access tokens. +- The API rejects upstream provider tokens. +- The API validates issuer, audience, signature, expiry, and activation. +- OAuth queries require `crm.read`. +- OAuth mutations require `crm.write`. +- Current workspace roles still control every protected action. +- Session-only routes reject OAuth and API-key credentials. +- Requests with multiple credential types fail. +- Browser sessions continue to work. +- Existing API keys continue to work. +- OpenAPI describes cookie, API-key, and bearer alternatives. +- Refresh tokens rotate successfully. +- Logout deletes local tokens and attempts server revocation. +- Logs contain no tokens, codes, headers, bodies, or query strings. +- Existing authentication and SSO tests pass. +- New OAuth end-to-end tests pass. +- The generated Dart REST client compiles. +- `git diff --check` passes. + +## 11. Non-goals + +This release does not add multi-tenancy. + +It does not add organization selection. + +It does not add tenant headers. + +It does not expose upstream identity-provider tokens. + +It does not replace browser session cookies. + +It does not replace existing API keys. + +It does not add client-credentials grants. + +It does not add dynamic client registration. + +It does not add token exchange. + +It does not add DPoP. + +It does not encode current roles in long-lived token claims. + +It does not move domain authorization into the authentication package. + +## 12. Delivery phases + +### 12.1 Phase A: dependency and schema foundation — complete + +Align Better Auth packages. + +Add the provider configuration. + +Generate and migrate the database schema. + +Reconcile the official mobile client at startup. + +### 12.2 Phase B: API bearer support — complete + +Add the request-principal resolver. + +Update tRPC and controller authentication. + +Add OAuth scope enforcement. + +Add OpenAPI bearer alternatives. + +Verify existing clients remain compatible. + +### 12.3 Phase C: authorization user experience — complete + +Verify discovery routing. + +Add the custom-client consent page. + +Add exact first-party redirect URIs. + +Reconcile the official Flutter client. + +### 12.4 Phase D: Flutter release — separate application work + +Implement system-browser sign-in. + +Implement secure token storage. + +Implement serialized refresh behavior. + +Implement logout and revocation. + +Run mobile platform security tests. + +### 12.5 Phase E: controlled enablement — pending deployment + +Enable the official OAuth client. + +Release to internal testers first. + +Monitor authorization and refresh failure rates. + +Expand the release after stable results. + +## 13. Operational metrics + +Track authorization requests by bounded outcome. + +Track token exchanges by bounded outcome. + +Track refresh attempts by bounded outcome. + +Track invalid audience and invalid issuer counts. + +Track insufficient-scope counts. + +Track client disablement events. + +Track signing-key rotation events. + +Do not attach tokens or authorization codes to metrics. + +Alert on repeated refresh reuse detection. + +Alert on sudden invalid-signature increases. + +Alert on sustained token endpoint failures. + +## 14. References + +- [CompCRM exposed API](./exposed-api.md) +- [CompCRM API architecture](./api.md) +- [CompCRM environment rules](./environment.md) +- [CompCRM design rules](./design.md) +- [Better Auth OAuth Provider](https://better-auth.com/docs/plugins/oauth-provider) +- [Better Auth 1.7 upgrade guide](https://better-auth.com/docs/guides/1-7-upgrade-guide) +- [Flutter AppAuth](https://pub.dev/packages/flutter_appauth) +- [Flutter Secure Storage](https://pub.dev/packages/flutter_secure_storage) +- [OpenAPI Generator Dart Dio](https://openapi-generator.tech/docs/generators/dart-dio/) +- [OAuth 2.0 for Native Apps](https://www.rfc-editor.org/rfc/rfc8252) +- [OAuth 2.0 Authorization Server Metadata](https://www.rfc-editor.org/rfc/rfc8414) +- [OAuth 2.0 Security Best Current Practice](https://www.rfc-editor.org/rfc/rfc9700) +- [Proof Key for Code Exchange](https://www.rfc-editor.org/rfc/rfc7636) + +## 15. Final recommendation + +CompCRM needs server changes before Flutter can use proper OIDC. + +The recommended solution uses Better Auth as the CompCRM authorization server. + +It issues CompCRM tokens through Authorization Code Flow with PKCE. + +It validates those tokens through one typed API principal. + +It combines OAuth scopes with current workspace role checks. + +It preserves browser cookies and integration API keys. + +This design adds mobile authentication without changing CRM tenancy or business authorization. diff --git a/docs/setup.md b/docs/setup.md index 03b8912f7..d9a15cc6c 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -13,6 +13,11 @@ bun run db:migrate && bun run db:seed bun run dev # app :3000, api :3001, agent :2000 ``` +The container creates the `crm` runtime role on a new volume. This role owns the +database and does not have `SUPERUSER` or `BYPASSRLS`. Both attributes bypass +tenant row-level security. A volume created before this role existed needs a +backup and a clean restore into a new volume before it can run this release. + Prisma from the repo root: `db:generate`, `db:migrate`, `db:push`, `db:reset`, `db:seed`, `db:studio`, `db:deploy`. @@ -113,6 +118,18 @@ builds, and the pages that touch them fail. Test schema changes locally, where worse: every preview applied its own migrations to the production database, so on 2026-08-07 the live schema ran six migrations ahead of the live code all day. +### Better Auth 1.7 account identities + +Migration `20260830221000_better_auth_account_identity` adds issuer-scoped account identities. +Back up the `account` and `user` tables before production deployment. +The migration preserves provider-scoped identities and checks for collisions. +Microsoft changes its account subject from `sub` to `oid` in Better Auth 1.7. +The migration reads `oid` from each stored Microsoft ID token. +The migration stops when a Microsoft row lacks that trusted mapping. +Repair that row from a verified Entra export before retrying deployment. +Do not infer the mapping from email addresses. +The migration removes account rows whose deleted SSO provider cannot supply an issuer. + ### `migrate deploy` is not proof the schema is right The build follows the deploy with `prisma migrate diff --exit-code` against @@ -142,6 +159,40 @@ DATABASE_URL="…" bunx prisma migrate diff \ strings, asserted by `packages/env/test/root.spec.ts`. **Generate your own secret**; never reuse one from an example, a tutorial, or another environment. +## XMPP agent gateway + +The gateway starts with the agent when `XMPP_COMPONENT_ENABLED=1`. +It uses the root `.env` and the same PostgreSQL database. + +Set these required values: + +```sh +XMPP_COMPONENT_ENABLED="1" +XMPP_COMPONENT_JID="gateway.agents.example.com" +XMPP_COMPONENT_SECRET="..." +XMPP_ORGANIZATION_ID="..." +AGENT_BRIDGE_SECRET="..." +``` + +The XMPP server must contain the component account. +The gateway connects through `XMPP_COMPONENT_SERVICE`. +The default address is `xmpp://127.0.0.1:5275`. + +`XMPP_ALLOWED_CALLER_DOMAINS` limits discovery and invocation. +`XMPP_ALLOW_DESTRUCTIVE_CALLERS` lists bare JIDs that can run destructive exports. +An empty destructive caller list denies every destructive export. + +The gateway exposes only `apps/agent/src/exports` registry entries. +It stores task state in PostgreSQL for recovery and replay protection. + +Run the live invocation check against a configured test server: + +```sh +XMPP_E2E_ALLOW_SELF_SIGNED=1 bun run --filter=agent e2e:xmpp +``` + +Use `XMPP_E2E_ALLOW_SELF_SIGNED` only with an isolated server certificate. + ## Tests ```sh @@ -155,6 +206,9 @@ bun run --filter=agent test # integration specs need DATABASE_URL + real Post name must end in `_test`; the suite deletes rows it expects to put back, so it refuses anything else. +The local `crm` role has `CREATEDB` only so this command can create `crm_test`. +Production runtime roles do not need that attribute. + **`migrate deploy` only applies migrations that are missing. It never removes a table, a column or a constraint the database has and the schema does not.** A `crm_test` built on a branch that was later abandoned therefore keeps that branch's diff --git a/docs/telemetry.md b/docs/telemetry.md index 6e1ed36aa..d49669f31 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -49,6 +49,12 @@ a fork somewhere else by editing that file. Nothing is sent while `NODE_ENV` is Once per install per day, from the API itself. Counts and distributions from grouped queries. Never a value from a row. +The rollup combines every workspace in the installation. It adds raw counts before computing +bands, rates, means, and distributions. `members_bucket` counts distinct users across all +workspace memberships. Capability booleans are true when any workspace enables the capability. +The model fields report the shared model when every workspace uses one model. They report +`mixed` and a null context window when workspace model settings differ. + **It runs in-process and needs no cron.** `TelemetryService` rolls up on boot and then hourly, and the day claim below makes all but the first of those a no-op. That covers both shapes an install comes in: a serverless deployment rolls up on a cold start, a long-running container on @@ -139,7 +145,6 @@ and the tool name only. `AgentEvent.data` itself is never sent. | `facts_by_method` | Counts by the `method` label a tool recorded | | `facts_by_evidence_kind` | Counts by evidence kind, from the `WEIGHTS` map in `lib/evidence.ts` | | `fact_dismissal_rate` | The share of decided facts a human rejected | -| `fact_decision_median_hours` | Median hours from `observedAt` to `decidedAt` | | `facts_superseded_within_7_days` | Facts the agent changed its mind about | Evidence kinds are matched against the eleven in `lib/evidence.ts`; anything else is `other`. diff --git a/docs/tracking.md b/docs/tracking.md index f4e8ecb77..4bbf05b35 100644 --- a/docs/tracking.md +++ b/docs/tracking.md @@ -4,9 +4,9 @@ A first-party script on the customer's own marketing site, a collector in the AP and one rule about what it is for: **a form submission becomes a contact.** Page views exist to give that contact a story, not to be a web-analytics product. -Everything here is one install's own website. There is no second tenant, no shared -pixel, and no vendor: the script is served from the same origin as the app, the -cookie is first-party, and the only thing that ever leaves the browser is a POST to +Each workspace tracks its own marketing sites with a separate site id and tenant +storage. Workspaces share no pixel identity, and there is no vendor. The script is +served from the app origin, the cookie is first-party, and the browser only posts to `/api/t/e` on the install's own API. ## Two scripts, and why @@ -75,6 +75,10 @@ stays with the route that needs it. The gauntlet, in order, in `TrackingIngestService.accept`: +The anonymous endpoints resolve the opaque site id through `trackingSiteLocator`. +This global table stores only the site id and organization id. Minting or rotating +a site id updates the locator and tenant settings in one transaction. + 1. **User agent** — the `BOT` pattern. 2. **Site id** — must be a live `cmp_` id, and `forSite` refuses a rotated one. 3. **Origin** — `originAllowed`. A missing `Origin` header is refused **even with @@ -178,6 +182,10 @@ deleted* comes to be true of email and not of forms. `POST /internal/tracking/retention`, nightly at 04:00 via `apps/api/vercel.json`, `CRON_SECRET` or nothing. +The job discovers organizations globally. It then rolls up and removes each +organization's records inside transaction-local tenant context. Expired tracking +counters use the same tenant boundary. + - **The cutoff is a whole UTC day**, `EVENT_RETENTION_DAYS` back and then truncated. A mid-day cutoff splits one calendar day across two nightly runs, and `trackedPageDaily` keeps the larger half of a day it saw twice — so the boundary diff --git a/package.json b/package.json index 534ec9d00..7bcf146c7 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "prepare": "git config core.hooksPath .githooks 2>/dev/null || true", "build": "turbo run build", "dev": "turbo run dev", + "dev:kaneo": "bun tools/kaneo-dev.ts", "lint": "turbo run lint", "lint:slop": "oxlint", "lint:dead": "knip --no-progress", @@ -18,31 +19,38 @@ "db:generate": "turbo run db:generate", "db:migrate": "turbo run db:migrate", "db:push": "turbo run db:push", + "db:rehearse": "bun run --filter=@crm/db db:rehearse", "db:reset": "turbo run db:reset", "db:seed": "turbo run db:seed", "db:studio": "turbo run db:studio", "db:test": "bun run --filter=@crm/db db:test" }, "devDependencies": { - "@biomejs/biome": "^2.4.10", - "@oxlint/plugins": "1.78.0", - "knip": "6.32.2", - "oxlint": "1.78.0", - "turbo": "^2.10.8", - "typescript": "5.9.2" + "@biomejs/biome": "^2.5.13", + "@oxlint/plugins": "^1.83.0", + "@paralleldrive/cuid2": "^3.3.0", + "drizzle-orm": "^0.45.2", + "knip": "^6.35.1", + "oxlint": "^1.83.0", + "turbo": "^2.10.12", + "typescript": "^7.0.2" }, "engines": { "node": ">=22" }, - "packageManager": "bun@1.3.12", + "packageManager": "bun@1.4.2", "devEngines": { "packageManager": { "name": "bun", - "version": "1.3.12" + "version": "1.4.2" } }, "workspaces": [ "apps/*", - "packages/*" - ] + "packages/*", + "packages/agent-xmpp/*" + ], + "patchedDependencies": { + "@better-auth/oauth-provider@1.7.3": "patches/@better-auth%2Foauth-provider@1.7.3.patch" + } } diff --git a/packages/agent-xmpp/core/package.json b/packages/agent-xmpp/core/package.json new file mode 100644 index 000000000..6abed437a --- /dev/null +++ b/packages/agent-xmpp/core/package.json @@ -0,0 +1,27 @@ +{ + "name": "@agent-xmpp/core", + "version": "0.1.0", + "description": "ProtoXEP schema validation and canonicalization", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && node ../../../node_modules/typescript/bin/tsc", + "test": "bun test src", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agent-xmpp/protocol": "workspace:*", + "ajv": "8.20.0" + }, + "devDependencies": { + "@types/node": "^26.5.0", + "typescript": "^7.0.2" + } +} diff --git a/packages/agent-xmpp/core/src/index.ts b/packages/agent-xmpp/core/src/index.ts new file mode 100644 index 000000000..20cdc26e1 --- /dev/null +++ b/packages/agent-xmpp/core/src/index.ts @@ -0,0 +1 @@ +export * from "./schema.js"; diff --git a/packages/agent-xmpp/core/src/schema-worker-lineage.test.ts b/packages/agent-xmpp/core/src/schema-worker-lineage.test.ts new file mode 100644 index 000000000..eea640fc3 --- /dev/null +++ b/packages/agent-xmpp/core/src/schema-worker-lineage.test.ts @@ -0,0 +1,79 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { EventEmitter } from "node:events"; + +interface WorkerRequest { + id: number; +} + +class FakeWorker extends EventEmitter { + static instances: FakeWorker[] = []; + request?: WorkerRequest; + + constructor() { + super(); + FakeWorker.instances.push(this); + } + + unref(): void {} + + postMessage(request: WorkerRequest): void { + this.request = request; + } + + terminate(): Promise { + return Promise.resolve(0); + } +} + +function workerAt(index: number): FakeWorker { + const worker = FakeWorker.instances[index]; + if (!worker) throw new Error(`missing worker ${index}`); + return worker; +} + +const { + closeSchemaWorkers, + SCHEMA_WORKER_FAILURE_LIMIT, + setWorkerFactory, + validateJsonBounded, +} = await import("./schema.ts?worker-lineage"); + +setWorkerFactory( + () => new FakeWorker() as unknown as import("node:worker_threads").Worker, +); + +afterAll(async () => { + await closeSchemaWorkers(); +}); + +describe("schema worker replacement lineages", () => { + it("stops one worker lineage after three interleaved failures", async () => { + const schema = { type: "string" } as const; + + for ( + let generation = 0; + generation < SCHEMA_WORKER_FAILURE_LIMIT; + generation++ + ) { + const unaffectedValidation = validateJsonBounded(schema, "valid"); + const failedValidation = validateJsonBounded(schema, "failed"); + const unaffectedWorker = workerAt(0); + const failingWorker = workerAt(generation + 1); + const unaffectedRequest = unaffectedWorker.request; + if (!unaffectedRequest) throw new Error("missing unaffected request"); + + unaffectedWorker.emit("message", { + id: unaffectedRequest.id, + errors: [], + }); + failingWorker.emit("error", new Error(`failure ${generation + 1}`)); + + await expect(unaffectedValidation).resolves.toEqual([]); + await expect(failedValidation).rejects.toThrow( + `failure ${generation + 1}`, + ); + } + + expect(FakeWorker.instances).toHaveLength(SCHEMA_WORKER_FAILURE_LIMIT + 1); + }); +}); diff --git a/packages/agent-xmpp/core/src/schema-worker.ts b/packages/agent-xmpp/core/src/schema-worker.ts new file mode 100644 index 000000000..34bccb6d9 --- /dev/null +++ b/packages/agent-xmpp/core/src/schema-worker.ts @@ -0,0 +1,63 @@ +import { parentPort } from "node:worker_threads"; + +import { + Ajv2020, + type ErrorObject, + type ValidateFunction, +} from "ajv/dist/2020.js"; +import { isXep0082DateTime } from "@agent-xmpp/protocol"; + +interface ValidationRequest { + id: number; + schemaHash: string; + schema: Record; + value: unknown; +} + +interface ValidationResponse { + id: number; + errors?: string[]; + failure?: string; +} + +const ajv = new Ajv2020({ + strict: true, + allErrors: true, + validateSchema: true, + unicodeRegExp: true, + ownProperties: true, +}); +ajv.addFormat("uri", { + type: "string", + validate(value: string): boolean { + try { + return new URL(value).protocol.length > 1; + } catch { + return false; + } + }, +}); +ajv.addFormat("date-time", isXep0082DateTime); + +const validators = new Map(); + +parentPort?.on("message", (request: ValidationRequest) => { + const response: ValidationResponse = { id: request.id }; + try { + let validate = validators.get(request.schemaHash); + if (!validate) { + validate = ajv.compile(request.schema); + validators.set(request.schemaHash, validate); + } + response.errors = validate(request.value) + ? [] + : (validate.errors ?? []).map(formatError); + } catch (error) { + response.failure = error instanceof Error ? error.message : String(error); + } + parentPort?.postMessage(response); +}); + +function formatError(error: ErrorObject): string { + return `${error.instancePath || "$"} ${error.message ?? error.keyword}`; +} diff --git a/packages/agent-xmpp/core/src/schema.test.ts b/packages/agent-xmpp/core/src/schema.test.ts new file mode 100644 index 000000000..18fde5c38 --- /dev/null +++ b/packages/agent-xmpp/core/src/schema.test.ts @@ -0,0 +1,23 @@ +import { afterAll, describe, expect, it } from "bun:test"; + +import { closeSchemaWorkers, validateJsonBounded } from "./schema.js"; + +afterAll(async () => { + await closeSchemaWorkers(); +}); + +describe("bounded schema validation", () => { + it("starts source workers with the declared loader", async () => { + await expect( + validateJsonBounded( + { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + additionalProperties: false, + }, + { value: "ok" }, + ), + ).resolves.toEqual([]); + }); +}); diff --git a/packages/agent-xmpp/core/src/schema.ts b/packages/agent-xmpp/core/src/schema.ts new file mode 100644 index 000000000..ac0324505 --- /dev/null +++ b/packages/agent-xmpp/core/src/schema.ts @@ -0,0 +1,571 @@ +import { createHash } from "node:crypto"; +import { Worker } from "node:worker_threads"; +import { + type AgentApiManifest, + assertUnicodeScalarString, + DEFAULT_JSON_LIMITS, + isApiVersion, + isNormalizedEndpointJid, + isToolName, + isXep0082DateTime, + type JsonSchema, + parseStrictJson, + type RegisteredTool, + XMPP_TOOL_EXTENSION_KEY, +} from "@agent-xmpp/protocol"; +import EVENT_SCHEMA_DOCUMENT from "@agent-xmpp/protocol/schema/event.schema.json" with { + type: "json", +}; +import MANIFEST_SCHEMA_DOCUMENT from "@agent-xmpp/protocol/schema/manifest.schema.json" with { + type: "json", +}; +import { + Ajv2020, + type ErrorObject, + type ValidateFunction, +} from "ajv/dist/2020.js"; + +export const MANIFEST_MAX_BYTES = 1_048_576; +export const SCHEMA_MAX_BYTES = 262_144; +export const SCHEMA_MAX_DEPTH = 64; +export const SCHEMA_MAX_NODES = 10_000; +export const SCHEMA_MAX_PATTERN_BYTES = 4_096; +export const SCHEMA_MAX_PENDING_VALIDATIONS = 64; + +export class SchemaResourceLimitError extends Error {} + +const MANIFEST_SCHEMA = MANIFEST_SCHEMA_DOCUMENT as JsonSchema; +const EVENT_SCHEMA = EVENT_SCHEMA_DOCUMENT as JsonSchema; + +const ajv = new Ajv2020({ + strict: true, + allErrors: true, + validateSchema: true, + unicodeRegExp: true, + ownProperties: true, +}); +ajv.addFormat("uri", { + type: "string", + validate(value: string): boolean { + try { + return new URL(value).protocol.length > 1; + } catch { + return false; + } + }, +}); +ajv.addFormat("date-time", isXep0082DateTime); +const manifestValidator = ajv.compile(MANIFEST_SCHEMA); +const validatorCache = new Map(); +const SCHEMA_WORKER_COUNT = 2; +export const SCHEMA_WORKER_FAILURE_LIMIT = 3; +const DEFAULT_SCHEMA_TIMEOUT_MS = 500; + +interface WorkerRequest { + id: number; + schemaHash: string; + schema: JsonSchema; + value: unknown; +} + +interface WorkerResponse { + id: number; + errors?: string[]; + failure?: string; +} + +interface PendingValidation { + request: WorkerRequest; + timeoutMs: number; + resolve: (errors: string[]) => void; + reject: (error: Error) => void; +} + +interface SchemaWorkerSlot { + worker: Worker; + consecutiveFailures: number; + pending?: PendingValidation; + timer?: ReturnType; +} + +let nextValidationId = 1; +const validationQueue: PendingValidation[] = []; +const schemaWorkers: SchemaWorkerSlot[] = []; + +/** RFC 8785 JSON Canonicalization Scheme serialization. */ +export function canonicalJson(value: unknown): string { + if (value === null) return "null"; + if (typeof value === "string") { + assertUnicodeScalarString(value); + return JSON.stringify(value); + } + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("non-finite JSON number"); + return Object.is(value, -0) ? "0" : JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + const object = value as Record; + return `{${Object.keys(object) + .sort() + .map((key) => `${canonicalJson(key)}:${canonicalJson(object[key])}`) + .join(",")}}`; + } + throw new Error(`unsupported JSON value: ${typeof value}`); +} + +/** XEP-0300 SHA-256 value: standard padded Base64, without an algorithm prefix. */ +export function digestJson(value: unknown): string { + return createHash("sha256") + .update(canonicalJson(value), "utf8") + .digest("base64"); +} + +export function assertJsonValueBounded( + value: unknown, + maxBytes: number, + label = "JSON value", +): void { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) + throw new Error("JSON byte limit must be a positive integer"); + const pending: Array<{ value: unknown; depth: number }> = [ + { value, depth: 0 }, + ]; + const visited = new WeakSet(); + let members = 0; + while (pending.length > 0) { + const current = pending.pop(); + if (!current) break; + if (current.depth > DEFAULT_JSON_LIMITS.maxDepth) { + throw new SchemaResourceLimitError( + `${label} exceeds JSON depth ${DEFAULT_JSON_LIMITS.maxDepth}`, + ); + } + if (typeof current.value === "string") { + if ( + Buffer.byteLength(current.value, "utf8") > + DEFAULT_JSON_LIMITS.maxStringBytes + ) { + throw new SchemaResourceLimitError( + `${label} contains an oversized JSON string`, + ); + } + continue; + } + if (current.value === null || typeof current.value !== "object") continue; + if (visited.has(current.value)) + throw new Error(`${label} contains a cyclic value`); + visited.add(current.value); + const entries = Array.isArray(current.value) + ? current.value.map((item) => [undefined, item] as const) + : Object.entries(current.value as Record); + members += entries.length; + if (members > DEFAULT_JSON_LIMITS.maxMembers) { + throw new SchemaResourceLimitError(`${label} exceeds JSON member limit`); + } + for (const [key, child] of entries) { + if ( + key !== undefined && + Buffer.byteLength(key, "utf8") > DEFAULT_JSON_LIMITS.maxStringBytes + ) { + throw new SchemaResourceLimitError( + `${label} contains an oversized JSON member name`, + ); + } + pending.push({ value: child, depth: current.depth + 1 }); + } + } + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error(`${label} is not a JSON value`); + if (Buffer.byteLength(encoded, "utf8") > maxBytes) { + throw new SchemaResourceLimitError(`${label} exceeds ${maxBytes} bytes`); + } +} + +export function parseManifestJson(text: string): AgentApiManifest { + return validateManifest( + parseStrictJson(text, { maxBytes: MANIFEST_MAX_BYTES }), + ); +} + +export function validateManifest(value: unknown): AgentApiManifest { + const canonical = canonicalJson(value); + if (Buffer.byteLength(canonical, "utf8") > MANIFEST_MAX_BYTES) { + throw new SchemaResourceLimitError("manifest exceeds 1 MiB"); + } + if (!manifestValidator(value)) + throw new Error( + `invalid manifest: ${formatErrors(manifestValidator.errors)}`, + ); + const manifest = value as AgentApiManifest; + if (!isNormalizedEndpointJid(manifest.agent.jid)) { + throw new Error("agent.jid must be a normalized endpoint bare JID"); + } + if (!isApiVersion(manifest.agent.version)) + throw new Error("agent.version must be a valid API version"); + for (const [member, uri] of [ + ["agent.homepage", manifest.agent.homepage], + ["agent.avatarUrl", manifest.agent.avatarUrl], + ] as const) { + if (uri !== undefined && !isPublicProfileHttpsUri(uri)) { + throw new Error( + `${member} must be an absolute lowercase HTTPS URI with a non-empty host and no userinfo`, + ); + } + } + const names = new Set(); + for (const tool of manifest.tools) { + assertUnicodeScalarString(tool.name); + if (!isToolName(tool.name)) throw new Error("invalid XML tool name"); + if (names.has(tool.name)) throw new Error(`duplicate tool: ${tool.name}`); + names.add(tool.name); + preflightSchema(tool.inputSchema, `tool ${tool.name} inputSchema`); + if (tool.outputSchema) + preflightSchema(tool.outputSchema, `tool ${tool.name} outputSchema`); + const extension = tool[XMPP_TOOL_EXTENSION_KEY] as + | Record + | undefined; + const defaultTimeout = extension?.defaultTimeoutSeconds; + const maximumTimeout = extension?.maximumTimeoutSeconds; + if ( + typeof defaultTimeout === "number" && + typeof maximumTimeout === "number" && + defaultTimeout > maximumTimeout + ) { + throw new Error( + `tool ${tool.name} default timeout exceeds maximum timeout`, + ); + } + } + return manifest; +} + +function isPublicProfileHttpsUri(value: string): boolean { + if ( + !value.startsWith("https://") || + !/^[A-Za-z0-9\-._~:/?[\]@!$&'()*+,;=%]+$/.test(value) || + /%(?![0-9A-Fa-f]{2})/.test(value) || + !URL.canParse(value) + ) { + return false; + } + const authority = value.slice("https://".length).split(/[/?]/, 1)[0] ?? ""; + if (authority.endsWith(":")) return false; + const uri = new URL(value); + return ( + uri.protocol === "https:" && + uri.hostname.length > 0 && + uri.username === "" && + uri.password === "" && + uri.hash === "" + ); +} + +export function registeredTools(manifest: AgentApiManifest): RegisteredTool[] { + return manifest.tools.map((tool) => ({ + ...tool, + inputSchemaHash: digestJson(tool.inputSchema), + outputSchemaHash: tool.outputSchema + ? digestJson(tool.outputSchema) + : undefined, + xmpp: tool[XMPP_TOOL_EXTENSION_KEY] as RegisteredTool["xmpp"], + })); +} + +export function validateJson(schema: JsonSchema, value: unknown): string[] { + preflightSchema(schema, "schema"); + const hash = digestJson(schema); + let validate = validatorCache.get(hash); + if (!validate) { + const compiled = ajv.compile(schema); + validatorCache.set(hash, compiled); + validate = compiled; + } + return validate(value) ? [] : (validate.errors ?? []).map(formatError); +} + +/** + * Evaluate caller-controlled schemas away from the host event loop. Workers are + * bounded and replaced after a timeout; each worker caches validators by the + * canonical schema hash. + */ +export function validateJsonBounded( + schema: JsonSchema, + value: unknown, + timeoutMs = DEFAULT_SCHEMA_TIMEOUT_MS, +): Promise { + preflightSchema(schema, "schema"); + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) { + return Promise.reject( + new Error("schema timeout must be a positive integer"), + ); + } + const pendingCount = + validationQueue.length + + schemaWorkers.filter((slot) => slot.pending).length; + if (pendingCount >= SCHEMA_MAX_PENDING_VALIDATIONS) { + return Promise.reject( + new SchemaResourceLimitError("schema validation queue is full"), + ); + } + return new Promise((resolve, reject) => { + validationQueue.push({ + request: { + id: nextValidationId++, + schemaHash: digestJson(schema), + schema, + value, + }, + timeoutMs, + resolve, + reject, + }); + ensureSchemaWorkers(); + dispatchValidationQueue(); + }); +} + +export function validateTaskEventPayload( + type: + | "status" + | "progress" + | "input_required" + | "completed" + | "failed" + | "cancelled", + payload: unknown, +): Promise { + const schema: JsonSchema = { ...EVENT_SCHEMA, $ref: `#/$defs/${type}` }; + delete schema.$id; + return validateJsonBounded(schema, payload); +} + +export async function closeSchemaWorkers(): Promise { + const workers = schemaWorkers.splice(0); + for (const slot of workers) { + if (slot.timer) clearTimeout(slot.timer); + slot.pending?.reject(new Error("schema validator worker closed")); + await slot.worker.terminate(); + } + while (validationQueue.length) + validationQueue + .shift() + ?.reject(new Error("schema validator worker closed")); +} + +function ensureSchemaWorkers(): void { + while (schemaWorkers.length < SCHEMA_WORKER_COUNT) { + const slot = createSchemaWorkerLineage(); + if (!slot) return; + schemaWorkers.push(slot); + } +} + +function createSchemaWorkerLineage( + consecutiveFailures = 0, +): SchemaWorkerSlot | undefined { + let failures = consecutiveFailures; + let lastError = new Error("schema validator worker failed"); + while (failures < SCHEMA_WORKER_FAILURE_LIMIT) { + try { + return createSchemaWorker(failures); + } catch (error) { + lastError = error instanceof Error ? error : lastError; + failures++; + } + } + rejectValidationQueue(lastError); + return undefined; +} + +let createWorkerInstance: (url: URL) => Worker = (url) => new Worker(url); + +export function setWorkerFactory(factory: (url: URL) => Worker): void { + createWorkerInstance = factory; +} + +function createSchemaWorker(consecutiveFailures: number): SchemaWorkerSlot { + const sourceMode = import.meta.url.endsWith(".ts"); + const worker = createWorkerInstance( + new URL( + sourceMode ? "./schema-worker.ts" : "./schema-worker.js", + import.meta.url, + ), + ); + worker.unref(); + const slot: SchemaWorkerSlot = { worker, consecutiveFailures }; + worker.on("message", (response: WorkerResponse) => + settleWorker(slot, response), + ); + worker.on("error", (error) => { + console.error("[schema] validator worker error:", error); + replaceWorker( + slot, + error instanceof Error ? error : new Error(String(error)), + ); + }); + worker.on("exit", (code) => { + if (schemaWorkers.includes(slot) && code !== 0) { + console.error(`[schema] validator worker exited with code ${code}`); + replaceWorker( + slot, + new Error(`schema validator worker exited with code ${code}`), + ); + } + }); + return slot; +} + +function dispatchValidationQueue(): void { + for (const slot of schemaWorkers) { + if (slot.pending) continue; + const pending = validationQueue.shift(); + if (!pending) return; + slot.pending = pending; + slot.timer = setTimeout(() => { + replaceWorker( + slot, + new SchemaResourceLimitError( + `schema validation timed out after ${pending.timeoutMs}ms`, + ), + ); + }, pending.timeoutMs); + slot.timer.unref?.(); + slot.worker.postMessage(pending.request); + } +} + +function settleWorker(slot: SchemaWorkerSlot, response: WorkerResponse): void { + const pending = slot.pending; + if (!pending || pending.request.id !== response.id) return; + if (slot.timer) clearTimeout(slot.timer); + slot.timer = undefined; + slot.pending = undefined; + if (response.failure) + pending.reject(new Error(`schema validation failed: ${response.failure}`)); + else { + slot.consecutiveFailures = 0; + pending.resolve(response.errors ?? []); + } + dispatchValidationQueue(); +} + +function replaceWorker(slot: SchemaWorkerSlot, error: Error): void { + const index = schemaWorkers.indexOf(slot); + if (index < 0) return; + if (slot.timer) clearTimeout(slot.timer); + slot.pending?.reject(error); + slot.pending = undefined; + void slot.worker.terminate(); + schemaWorkers.splice(index, 1); + const consecutiveFailures = slot.consecutiveFailures + 1; + if (consecutiveFailures >= SCHEMA_WORKER_FAILURE_LIMIT) { + rejectValidationQueue(error); + return; + } + const replacement = createSchemaWorkerLineage(consecutiveFailures); + if (!replacement) return; + schemaWorkers.splice(index, 0, replacement); + dispatchValidationQueue(); +} + +function rejectValidationQueue(error: Error): void { + while (validationQueue.length) validationQueue.shift()?.reject(error); +} + +export function preflightSchema(schema: JsonSchema, label: string): void { + assertSchemaComplexity(schema, label); + const encoded = canonicalJson(schema); + if (Buffer.byteLength(encoded, "utf8") > SCHEMA_MAX_BYTES) { + throw new SchemaResourceLimitError(`${label} exceeds 256 KiB`); + } + if (!ajv.validateSchema(schema)) + throw new Error( + `${label} is not a valid JSON Schema: ${formatErrors(ajv.errors)}`, + ); +} + +function assertSchemaComplexity(schema: JsonSchema, label: string): void { + const pending: Array<{ value: unknown; depth: number }> = [ + { value: schema, depth: 0 }, + ]; + let nodes = 0; + while (pending.length > 0) { + const current = pending.pop(); + if (!current) break; + const { value, depth } = current; + if (++nodes > SCHEMA_MAX_NODES) { + throw new SchemaResourceLimitError( + `${label} exceeds ${SCHEMA_MAX_NODES} nodes`, + ); + } + if (depth > SCHEMA_MAX_DEPTH) { + throw new SchemaResourceLimitError( + `${label} exceeds depth ${SCHEMA_MAX_DEPTH}`, + ); + } + if (!value || typeof value !== "object") continue; + const object = value as Record; + for (const keyword of ["$ref", "$dynamicRef"] as const) { + const reference = object[keyword]; + if (typeof reference === "string" && !reference.startsWith("#")) { + throw new Error(`${label} contains forbidden external ${keyword}`); + } + } + if (object.$vocabulary && typeof object.$vocabulary === "object") { + for (const [vocabulary, required] of Object.entries( + object.$vocabulary as Record, + )) { + if ( + required === true && + !vocabulary.startsWith("https://json-schema.org/draft/2020-12/vocab/") + ) { + throw new Error( + `${label} requires unsupported vocabulary ${vocabulary}`, + ); + } + } + } + if (typeof object.pattern === "string") + assertPattern(object.pattern, label); + if ( + object.patternProperties && + typeof object.patternProperties === "object" + ) { + for (const pattern of Object.keys( + object.patternProperties as Record, + )) { + assertPattern(pattern, label); + } + } + const children = Array.isArray(value) ? value : Object.values(object); + for (const child of children) + pending.push({ value: child, depth: depth + 1 }); + } +} + +function assertPattern(pattern: string, label: string): void { + if (Buffer.byteLength(pattern, "utf8") > SCHEMA_MAX_PATTERN_BYTES) { + throw new SchemaResourceLimitError( + `${label} contains a pattern exceeding ${SCHEMA_MAX_PATTERN_BYTES} bytes`, + ); + } + try { + new RegExp(pattern, "u"); + } catch (error) { + throw new Error(`${label} contains an invalid ECMA-262 pattern`, { + cause: error, + }); + } +} + +function formatError(error: ErrorObject): string { + return `${error.instancePath || "$"} ${error.message ?? error.keyword}`; +} + +function formatErrors(errors: ErrorObject[] | null | undefined): string { + return ( + (errors ?? []).map(formatError).join("; ") || "schema validation failed" + ); +} diff --git a/packages/agent-xmpp/core/tsconfig.json b/packages/agent-xmpp/core/tsconfig.json new file mode 100644 index 000000000..055dc08e3 --- /dev/null +++ b/packages/agent-xmpp/core/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/agent-xmpp/gateway/package.json b/packages/agent-xmpp/gateway/package.json new file mode 100644 index 000000000..75cb6c6fe --- /dev/null +++ b/packages/agent-xmpp/gateway/package.json @@ -0,0 +1,30 @@ +{ + "name": "@agent-xmpp/gateway", + "version": "0.1.0", + "description": "XMPP component and ProtoXEP wire codecs for agent gateways", + "type": "module", + "main": "./dist/embedded-gateway.js", + "types": "./dist/embedded-gateway.d.ts", + "exports": { + ".": { + "types": "./dist/embedded-gateway.d.ts", + "import": "./dist/embedded-gateway.js" + } + }, + "scripts": { + "build": "rm -rf dist && node ../../../node_modules/typescript/bin/tsc", + "test": "bun test src", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agent-xmpp/core": "workspace:*", + "@agent-xmpp/protocol": "workspace:*", + "@xmpp/component": "0.14.0", + "@xmpp/xml": "0.14.0", + "ulid": "3.0.2" + }, + "devDependencies": { + "@types/node": "^26.5.0", + "typescript": "^7.0.2" + } +} diff --git a/packages/agent-xmpp/gateway/src/agent-api-disco.ts b/packages/agent-xmpp/gateway/src/agent-api-disco.ts new file mode 100644 index 000000000..67b592d0a --- /dev/null +++ b/packages/agent-xmpp/gateway/src/agent-api-disco.ts @@ -0,0 +1,580 @@ +import { + AGENT_API_NS, + AGENT_DIRECTORY_NS, + AGENT_ENDPOINT_NS, + AGENT_TASK_NS, + AGENT_TOOL_NS, + DEFAULT_PROTOCOL_NAMESPACES, + JSON_MEDIA_TYPE, + JSON_SCHEMA_MEDIA_TYPE, + isApiVersion, + isToolName, + type AgentApiManifest, + type AgentXmppNamespaces, + type RegisteredAgent, + type RegisteredTool, + parseStrictJson, +} from "@agent-xmpp/protocol"; +import { canonicalJson, validateManifest } from "@agent-xmpp/core"; +import { xml, type Element } from "@xmpp/xml"; + +import { buildHash, parseHash } from "./hash-codec.js"; +import { ProtocolError } from "./protocol-error.js"; +import { buildRsm, pageRsm, parseRsm } from "./rsm-codec.js"; +import { VCARD_TEMP_NS } from "./xep-plugins/vcard.js"; + +export const DISCO_INFO_NS = "http://jabber.org/protocol/disco#info"; +export const DISCO_ITEMS_NS = "http://jabber.org/protocol/disco#items"; +export const DATA_FORMS_NS = "jabber:x:data"; +export const SEARCH_NS = "jabber:iq:search"; +export { + AGENT_DIRECTORY_NS, + AGENT_API_NS, + AGENT_TOOL_NS, + AGENT_ENDPOINT_NS, + AGENT_TASK_NS, +}; + +export interface ManifestRequest { + version?: string; +} + +export interface SchemaRequest { + tool: string; + version: string; + direction: "input" | "output"; + manifestHash: string; +} + +interface PayloadShape { + requiredAttributes?: readonly string[]; + optionalAttributes?: readonly string[]; + children?: readonly { name: string; xmlns?: string }[]; +} + +function assertPayloadShape(payload: Element, shape: PayloadShape): void { + const required = shape.requiredAttributes ?? []; + const allowed = new Set([ + "xmlns", + ...required, + ...(shape.optionalAttributes ?? []), + ]); + if ( + required.some( + (name) => payload.attrs[name] === undefined || payload.attrs[name] === "", + ) || + Object.keys(payload.attrs).some((name) => !allowed.has(name)) + ) { + throw new Error(`${payload.name} has invalid attributes`); + } + + const expectedChildren = shape.children ?? []; + const actualChildren = payload.getChildElements(); + if ( + payload.children.some( + (child) => typeof child === "string" && child.trim() !== "", + ) || + actualChildren.length !== expectedChildren.length || + actualChildren.some( + (child, index) => + child.name !== expectedChildren[index]!.name || + (expectedChildren[index]!.xmlns !== undefined && + child.attrs.xmlns !== expectedChildren[index]!.xmlns), + ) + ) { + throw new Error(`${payload.name} has invalid children`); + } +} + +function requestPayload( + request: Element, + name: string, + namespace: string, + expectedIqType: "get" | "set", +): Element | null { + if (request.name !== "iq") return null; + const payload = request.getChild(name, namespace); + if (!payload) return null; + if (request.attrs.type !== expectedIqType) + throw new Error(`${name} requires an IQ of type ${expectedIqType}`); + return payload; +} + +export function parseManifestRequest( + request: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): ManifestRequest | null { + const payload = requestPayload( + request, + "manifest-request", + namespaces.api, + "get", + ); + if (!payload) return null; + assertPayloadShape(payload, { optionalAttributes: ["version"] }); + const version = + payload.attrs.version === undefined + ? undefined + : String(payload.attrs.version); + if (version !== undefined && !isApiVersion(version)) + throw new Error("manifest-request has an invalid version"); + return version === undefined ? {} : { version }; +} + +export function parseSchemaRequest( + request: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): SchemaRequest | null { + const payload = requestPayload( + request, + "schema-request", + namespaces.api, + "get", + ); + if (!payload) return null; + assertPayloadShape(payload, { + requiredAttributes: ["tool", "version", "direction"], + children: [{ name: "hash", xmlns: namespaces.hashes }], + }); + const tool = String(payload.attrs.tool); + const version = String(payload.attrs.version); + const direction = String(payload.attrs.direction); + if ( + !isToolName(tool) || + !isApiVersion(version) || + (direction !== "input" && direction !== "output") + ) { + throw new Error("schema-request has invalid attributes"); + } + return { + tool, + version, + direction, + manifestHash: parseHash(payload).value, + }; +} + +function resultIq(request: Element, from: string, child: Element): Element { + return xml( + "iq", + { type: "result", id: request.attrs.id, from, to: request.attrs.from }, + child, + ); +} + +function field(name: string, value: string, type?: string): Element { + return xml( + "field", + { var: name, ...(type ? { type } : {}) }, + xml("value", {}, value), + ); +} + +function resultForm(formType: string, fields: Element[]): Element { + return xml( + "x", + { xmlns: DATA_FORMS_NS, type: "result" }, + field("FORM_TYPE", formType, "hidden"), + ...fields, + ); +} + +function features(...values: string[]): Element[] { + return values.map((value) => xml("feature", { var: value })); +} + +const HUMAN_FEATURES = [ + "urn:xmpp:ping", + "urn:xmpp:receipts", + "http://jabber.org/protocol/chatstates", + "urn:xmpp:reply:0", + "urn:xmpp:sid:0", + "urn:xmpp:hints", +]; + +export function buildGatewayInfo( + request: Element, + componentJid: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return resultIq( + request, + componentJid, + xml( + "query", + { xmlns: DISCO_INFO_NS }, + xml("identity", { + category: "automation", + type: "agent-gateway", + name: "NanoClaw XMPP Agent Gateway", + }), + ...features( + DISCO_INFO_NS, + DISCO_ITEMS_NS, + SEARCH_NS, + DATA_FORMS_NS, + namespaces.directory, + namespaces.admin, + ...HUMAN_FEATURES, + ), + ), + ); +} + +export function buildDirectoryInfo( + request: Element, + componentJid: string, +): Element { + return resultIq( + request, + componentJid, + xml( + "query", + { xmlns: DISCO_INFO_NS, node: AGENT_DIRECTORY_NS }, + xml("identity", { + category: "automation", + type: "agent-directory", + name: "NanoClaw Agent Directory", + }), + ...features(DISCO_INFO_NS, DISCO_ITEMS_NS), + ), + ); +} + +export function buildAgentDirectory( + request: Element, + componentJid: string, + agents: RegisteredAgent[], + _namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const query = request.getChild("query", DISCO_ITEMS_NS)!; + const page = pageRsm( + agents, + (agent) => agent.manifest.agent.jid, + parseRsm(query), + ); + return resultIq( + request, + componentJid, + xml( + "query", + { + xmlns: DISCO_ITEMS_NS, + ...(query.attrs.node ? { node: query.attrs.node } : {}), + }, + ...page.items.map((agent) => + xml("item", { + jid: agent.manifest.agent.jid, + name: agent.manifest.agent.title ?? agent.manifest.agent.name, + }), + ), + buildRsm(page), + ), + ); +} + +export function buildAgentInfo( + request: Element, + agent: RegisteredAgent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const identity = agent.manifest.agent; + const taskFeatures = new Set([namespaces.task]); + for (const tool of agent.tools) { + if (tool.xmpp?.supportsProgress) taskFeatures.add(namespaces.progress); + if (tool.xmpp?.supportsCancellation) taskFeatures.add(namespaces.cancel); + if (tool.xmpp?.supportsInput) taskFeatures.add(namespaces.input); + } + return resultIq( + request, + identity.jid, + xml( + "query", + { xmlns: DISCO_INFO_NS }, + xml("identity", { + category: "automation", + type: "agent-endpoint", + name: identity.title ?? identity.name, + }), + ...features( + DISCO_INFO_NS, + DISCO_ITEMS_NS, + namespaces.endpoint, + namespaces.manifest, + namespaces.schema, + ...taskFeatures, + VCARD_TEMP_NS, + ...HUMAN_FEATURES, + ), + resultForm(namespaces.endpointInfo, [ + field("server_name", identity.name), + field("server_title", identity.title ?? identity.name), + ...(identity.description + ? [field("description", identity.description)] + : []), + field("version", identity.version), + field("manifest_hash_algo", "sha-256"), + field("manifest_hash_value", agent.manifestHash), + field("cold_start_supported", "1"), + field("request_replay_seconds", "86400"), + ]), + ), + ); +} + +export function buildToolItems( + request: Element, + agent: RegisteredAgent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const query = request.getChild("query", DISCO_ITEMS_NS)!; + const page = pageRsm( + agent.tools, + (tool) => toolNode(agent.manifest.agent.version, tool.name, namespaces), + parseRsm(query), + (tool) => tool.name, + ); + return resultIq( + request, + agent.manifest.agent.jid, + xml( + "query", + { + xmlns: DISCO_ITEMS_NS, + node: toolsNode(agent.manifest.agent.version, namespaces), + }, + ...page.items.map((tool) => + xml("item", { + jid: agent.manifest.agent.jid, + node: toolNode(agent.manifest.agent.version, tool.name, namespaces), + name: tool.title ?? tool.name, + }), + ), + buildRsm(page), + ), + ); +} + +export function buildToolCollectionInfo( + request: Element, + agent: RegisteredAgent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return resultIq( + request, + agent.manifest.agent.jid, + xml( + "query", + { + xmlns: DISCO_INFO_NS, + node: toolsNode(agent.manifest.agent.version, namespaces), + }, + xml("identity", { + category: "automation", + type: "agent-tool-collection", + }), + ...features(DISCO_INFO_NS, DISCO_ITEMS_NS), + ), + ); +} + +export function buildToolInfo( + request: Element, + agent: RegisteredAgent, + tool: RegisteredTool, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const taskFeatures: string[] = [namespaces.task]; + if (tool.xmpp?.supportsProgress) taskFeatures.push(namespaces.progress); + if (tool.xmpp?.supportsCancellation) taskFeatures.push(namespaces.cancel); + if (tool.xmpp?.supportsInput) taskFeatures.push(namespaces.input); + const optionalBoolean = ( + name: string, + value: boolean | undefined, + ): Element[] => (value === undefined ? [] : [field(name, value ? "1" : "0")]); + return resultIq( + request, + agent.manifest.agent.jid, + xml( + "query", + { + xmlns: DISCO_INFO_NS, + node: toolNode(agent.manifest.agent.version, tool.name, namespaces), + }, + xml("identity", { + category: "automation", + type: "agent-tool", + name: tool.title ?? tool.name, + }), + ...features(DISCO_INFO_NS, namespaces.tool, ...taskFeatures), + resultForm(namespaces.toolInfo, [ + field("name", tool.name), + ...(tool.title ? [field("title", tool.title)] : []), + ...(tool.description ? [field("description", tool.description)] : []), + field("api_version", agent.manifest.agent.version), + field("input_schema_hash_algo", "sha-256"), + field("input_schema_hash_value", tool.inputSchemaHash), + ...(tool.outputSchemaHash + ? [ + field("output_schema_hash_algo", "sha-256"), + field("output_schema_hash_value", tool.outputSchemaHash), + ] + : []), + ...optionalBoolean("read_only", tool.annotations?.readOnlyHint), + ...optionalBoolean("destructive", tool.annotations?.destructiveHint), + ...optionalBoolean("idempotent", tool.annotations?.idempotentHint), + ...optionalBoolean("open_world", tool.annotations?.openWorldHint), + ]), + ), + ); +} + +export function buildSchemaResult( + request: Element, + agent: RegisteredAgent, + tool: RegisteredTool, + direction: "input" | "output", + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const schema = direction === "input" ? tool.inputSchema : tool.outputSchema; + const schemaHash = + direction === "input" ? tool.inputSchemaHash : tool.outputSchemaHash; + if (!schema || !schemaHash) throw new Error("schema not found"); + const canonicalSchema = canonicalJson(schema); + return resultIq( + request, + agent.manifest.agent.jid, + xml( + "schema", + { + xmlns: namespaces.api, + tool: tool.name, + version: agent.manifest.agent.version, + direction, + "media-type": JSON_SCHEMA_MEDIA_TYPE, + }, + xml("manifest-hash", {}, buildHash(agent.manifestHash)), + xml("schema-hash", {}, buildHash(schemaHash)), + xml("json", {}, canonicalSchema), + ), + ); +} + +export function buildManifestResult( + request: Element, + agent: RegisteredAgent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return resultIq( + request, + agent.manifest.agent.jid, + xml( + "manifest", + { + xmlns: namespaces.api, + version: agent.manifest.agent.version, + "media-type": JSON_MEDIA_TYPE, + }, + buildHash(agent.manifestHash), + xml("json", {}, agent.canonicalManifest), + ), + ); +} + +export function toolsNode( + version: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): string { + if (!isApiVersion(version)) throw new Error("invalid API version"); + return `${namespaces.tools}#${version}`; +} + +export function toolsVersionFromNode( + node: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): string | null { + const prefix = `${namespaces.tools}#`; + if (!node.startsWith(prefix)) return null; + const version = node.slice(prefix.length); + return isApiVersion(version) ? version : null; +} + +export function toolNode( + version: string, + name: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): string { + if (!isApiVersion(version)) throw new Error("invalid API version"); + if (!isToolName(name)) throw new Error("invalid tool name"); + return `${namespaces.tool}#${version}#${Buffer.from(name, "utf8").toString("base64url")}`; +} + +export function toolFromNode( + node: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): { version: string; name: string } | null { + const prefix = `${namespaces.tool}#`; + if (!node.startsWith(prefix)) return null; + const separator = node.indexOf("#", prefix.length); + if (separator < 0) return null; + const version = node.slice(prefix.length, separator); + const encoded = node.slice(separator + 1); + if ( + !isApiVersion(version) || + !encoded || + encoded.includes("=") || + !/^[A-Za-z0-9_-]+$/.test(encoded) + ) + return null; + try { + const bytes = Buffer.from(encoded, "base64url"); + if (bytes.toString("base64url") !== encoded) return null; + const name = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + if (!isToolName(name)) return null; + return { version, name }; + } catch { + return null; + } +} + +export function parseManifestRegistration( + request: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): AgentApiManifest | null { + if (request.name !== "iq" || request.attrs.type !== "set") return null; + const manifest = request + .getChild("register", namespaces.api) + ?.getChild("manifest", namespaces.api); + if (!manifest || manifest.attrs["media-type"] !== JSON_MEDIA_TYPE) + return null; + try { + return validateManifest( + parseStrictJson(manifest.getText(), { maxBytes: 1_048_576 }), + ); + } catch (error) { + throw new ProtocolError( + "bad-request", + error instanceof Error ? error.message : "Invalid manifest", + ); + } +} + +export function buildManifestRegistrationResult( + request: Element, + agent: RegisteredAgent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return resultIq( + request, + request.attrs.to ? String(request.attrs.to) : agent.manifest.agent.jid, + xml( + "registered", + { + xmlns: namespaces.api, + jid: agent.manifest.agent.jid, + version: agent.manifest.agent.version, + }, + buildHash(agent.manifestHash), + ), + ); +} diff --git a/packages/agent-xmpp/gateway/src/agent-send.ts b/packages/agent-xmpp/gateway/src/agent-send.ts new file mode 100644 index 000000000..6bfee1053 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/agent-send.ts @@ -0,0 +1,61 @@ +/** + * Emits XEP-0085 Chat State Notifications on the agent's behalf (composing while + * the agent works, paused/inactive when it stops). States are directed to the same + * 1:1 resource or MUC room the inbound message came from. + * + * @see https://xmpp.org/extensions/xep-0085.html + */ +import type { Element } from "@xmpp/xml"; + +import type { InboundChatTargets } from "./delivery.js"; +import { + buildComposingStanza, + buildInactiveStanza, + buildPausedStanza, +} from "./xep-plugins/chatstate.js"; + +async function sendChatStateForAgent( + sendOutbound: (stanza: Element) => Promise, + agentJid: string, + targets: Pick, + state: "composing" | "paused" | "inactive", +): Promise { + const build = + state === "composing" + ? buildComposingStanza + : state === "paused" + ? buildPausedStanza + : buildInactiveStanza; + await sendOutbound( + build({ + from: agentJid, + to: targets.to, + threadId: targets.threadId, + groupchat: targets.groupchat, + }), + ); +} + +export async function sendComposingForAgent( + sendOutbound: (stanza: Element) => Promise, + agentJid: string, + targets: Pick, +): Promise { + await sendChatStateForAgent(sendOutbound, agentJid, targets, "composing"); +} + +export async function sendPausedForAgent( + sendOutbound: (stanza: Element) => Promise, + agentJid: string, + targets: Pick, +): Promise { + await sendChatStateForAgent(sendOutbound, agentJid, targets, "paused"); +} + +export async function sendInactiveForAgent( + sendOutbound: (stanza: Element) => Promise, + agentJid: string, + targets: Pick, +): Promise { + await sendChatStateForAgent(sendOutbound, agentJid, targets, "inactive"); +} diff --git a/packages/agent-xmpp/gateway/src/config.ts b/packages/agent-xmpp/gateway/src/config.ts new file mode 100644 index 000000000..dec8cc8b5 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/config.ts @@ -0,0 +1,112 @@ +import { + DEFAULT_PROTOCOL_NAMESPACES, + type AgentXmppNamespaces, +} from "@agent-xmpp/protocol"; + +export interface GatewayConfig { + gatewayId: string; + /** Component JID, e.g. gateway.agents.example */ + componentJid: string; + /** Delegated domain for virtual agent JIDs, e.g. agents.example */ + agentDomain: string; + /** XMPP server domain used as the XEP-0199 keepalive target. */ + serverDomain: string; + /** xmpp://host:5275 or xmpps://host:5347 */ + componentService: string; + componentSecret: string; + defaultAgentJid: string; + /** Default inherited language for human-readable XML text. */ + xmlLang?: string; + /** XEP-0184: how long to wait for a before a resend is due (ms). */ + receiptTimeoutMs: number; + /** + * XEP-0184: max resends of an un-acked message before giving up. + * Default 0 (observe-only): absence of a receipt is NOT evidence of failure — many + * clients/servers don't implement receipts and XMPP doesn't guarantee dedup of equal + * stanza/origin ids, so resending would duplicate ordinary messages. Only raise this + * for a deployment where every peer is known to support XEP-0184 and dedups. + */ + receiptMaxResends: number; + /** How often the resend sweep runs (ms). */ + receiptSweepMs: number; + /** Initial reconnect delay; subsequent failures back off exponentially. */ + reconnectInitialMs: number; + /** Maximum reconnect delay. */ + reconnectMaxMs: number; + /** Send XEP-0199 after this much connection inactivity. */ + pingIntervalMs: number; + /** Time allowed for an XEP-0199 response. */ + pingTimeoutMs: number; + /** Consecutive ping failures before forcing a reconnect. */ + pingFailureThreshold: number; + /** Maximum concurrent inbound or outbound IQ requests held by the component. */ + maxPendingIqRequests?: number; + protocolNamespaces?: AgentXmppNamespaces; +} + +/** Non-negative integer (0 is meaningful, e.g. observe-only resends). */ +function envNonNegInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + const n = Number(raw); + return Number.isInteger(n) && n >= 0 ? n : fallback; +} + +/** Strictly-positive integer — for interval/timeout values where 0 would busy-loop. */ +function envPosInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + const n = Number(raw); + return Number.isInteger(n) && n > 0 ? n : fallback; +} + +function env(name: string, fallback?: string): string { + const v = process.env[name]; + if (v !== undefined && v !== "") return v; + if (fallback !== undefined) return fallback; + throw new Error(`Missing required env: ${name}`); +} + +function envLanguageTag(name: string): string | undefined { + const value = process.env[name]?.trim(); + if (!value) return undefined; + return value.length <= 64 && /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(value) + ? value + : undefined; +} + +export function loadConfig(): GatewayConfig { + const componentJid = env("XMPP_COMPONENT_JID"); + const agentDomain = + process.env.XMPP_AGENT_DOMAIN || + componentJid.split(".").slice(1).join(".") || + componentJid; + const inferredServerDomain = + componentJid.split(".").slice(1).join(".") || componentJid; + const reconnectInitialMs = envPosInt("XMPP_RECONNECT_INITIAL_MS", 1_000); + const reconnectMaxMs = Math.max( + reconnectInitialMs, + envPosInt("XMPP_RECONNECT_MAX_MS", 60_000), + ); + return { + gatewayId: process.env.XMPP_GATEWAY_ID || "gw-1", + componentJid, + agentDomain, + serverDomain: process.env.XMPP_SERVER_DOMAIN || inferredServerDomain, + componentService: env("XMPP_COMPONENT_SERVICE", "xmpp://127.0.0.1:5275"), + componentSecret: env("XMPP_COMPONENT_SECRET"), + defaultAgentJid: + process.env.XMPP_DEFAULT_AGENT_JID || `assistant@${agentDomain}`, + xmlLang: envLanguageTag("XMPP_XML_LANG"), + receiptTimeoutMs: envPosInt("XMPP_RECEIPT_TIMEOUT_MS", 30_000), + receiptMaxResends: envNonNegInt("XMPP_RECEIPT_MAX_RESENDS", 0), + receiptSweepMs: envPosInt("XMPP_RECEIPT_SWEEP_MS", 10_000), + reconnectInitialMs, + reconnectMaxMs, + pingIntervalMs: envPosInt("XMPP_PING_INTERVAL_MS", 60_000), + pingTimeoutMs: envPosInt("XMPP_PING_TIMEOUT_MS", 10_000), + pingFailureThreshold: envPosInt("XMPP_PING_FAILURE_THRESHOLD", 2), + maxPendingIqRequests: envPosInt("XMPP_MAX_PENDING_IQ_REQUESTS", 256), + protocolNamespaces: DEFAULT_PROTOCOL_NAMESPACES, + }; +} diff --git a/packages/agent-xmpp/gateway/src/delivery.ts b/packages/agent-xmpp/gateway/src/delivery.ts new file mode 100644 index 000000000..4fa1836f5 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/delivery.ts @@ -0,0 +1,156 @@ +/** + * Inbound delivery gating and routing. 1:1 (XMPP `chat`) messages always pass; + * groupchat (XEP-0045) messages are delivered only when the agent is mentioned — + * via XEP-0513 explicit mentions or the plaintext `@nick` fallback in routing.ts. + * + * @see https://xmpp.org/extensions/xep-0045.html + * @see https://xmpp.org/extensions/xep-0513.html + */ +import type { + AgentMessage, + BridgeFormResponsePayload, + BridgeInboundPayload, +} from "@agent-xmpp/protocol"; +import { agentMessageText } from "@agent-xmpp/protocol"; + +import type { GatewayConfig } from "./config.js"; +import type { GatewayRuntimeMailbox } from "./runtime-mailbox.js"; +import { buildInboundEnvelope } from "./xep-plugins/message.js"; +import { bareJid } from "./xep-plugins/jid.js"; +import { mucRoomFromStanza } from "./xep-plugins/muc.js"; +import { + isMentionForAgent, + shouldDeliverInbound, +} from "./xep-plugins/routing.js"; + +export interface InboundDeliveryContext { + agentMsg: AgentMessage; + agentJid: string; + deliveryId: string; + stanzaType: string; + from: string; + redelivered?: boolean; +} + +export function shouldAcceptStanza( + stanzaType: string, + from: string, + bodyText: string, + agentNick: string, +): boolean { + const room = mucRoomFromStanza(from); + const isGroup = stanzaType === "groupchat" || !!room; + const isMention = isMentionForAgent(stanzaType, bodyText, agentNick); + return shouldDeliverInbound(stanzaType, isGroup, isMention); +} + +export interface InboundChatTargets { + /** Reply/typing destination and host router session key: MUC room JID or bare sender JID. */ + to: string; + threadId: string | null; + /** True for MUC/groupchat traffic. */ + groupchat: boolean; +} + +/** Resolve where replies and typing notifications for an inbound stanza should go. */ +export function resolveInboundChatTargets( + from: string, + stanzaType: string, + agentMsg: Pick, +): InboundChatTargets { + const room = mucRoomFromStanza(from); + const groupchat = stanzaType === "groupchat" || !!room; + const to = groupchat && room ? room : bareJid(agentMsg.from); + const threadId = agentMsg.threadId || (groupchat ? room || null : null); + return { to, threadId, groupchat }; +} + +export function buildBridgePayload( + config: GatewayConfig, + ctx: InboundDeliveryContext, +): BridgeInboundPayload { + const { agentMsg, agentJid, deliveryId, stanzaType, from, redelivered } = ctx; + const { + to: platformId, + threadId, + groupchat: isGroup, + } = resolveInboundChatTargets(from, stanzaType, agentMsg); + const bodyText = agentMessageText(agentMsg); + const agentNick = agentJid.split("@")[0]; + const isMention = isMentionForAgent(stanzaType, bodyText, agentNick); + + const envelope = buildInboundEnvelope( + agentMsg, + config.gatewayId, + deliveryId, + { + stanzaId: agentMsg.id, + stableId: agentMsg.id, + stanzaType: stanzaType as "chat" | "groupchat", + }, + redelivered, + ); + + return { + platformId, + // RFC 6121 section 8.5.2.1: reply to the originating resource. Keeping + // routing on the bare JID avoids creating one NanoClaw session per client. + replyTo: isGroup ? undefined : from, + threadId, + agentJid, + isMention, + isGroup, + envelope, + }; +} + +export async function pushInboundToBridge( + config: GatewayConfig, + mailbox: GatewayRuntimeMailbox, + ctx: InboundDeliveryContext, +): Promise { + await mailbox.deliverInbound(buildBridgePayload(config, ctx)); +} + +export interface FormResponseContext { + agentJid: string; + from: string; + stanzaType: string; + questionId: string; + selectedIndex: number; +} + +export function buildFormResponsePayload( + _config: GatewayConfig, + ctx: FormResponseContext, +): BridgeFormResponsePayload { + const { + to: platformId, + threadId, + groupchat: isGroup, + } = resolveInboundChatTargets(ctx.from, ctx.stanzaType, { + from: ctx.from, + threadId: undefined, + }); + + return { + type: "form_response", + agentJid: ctx.agentJid, + platformId, + threadId, + questionId: ctx.questionId, + selectedIndex: ctx.selectedIndex, + // In a MUC the occupant identity is the resource (room@muc/nick); keep the full JID so + // the answer is attributed to the responder, not to the room. + userId: isGroup ? ctx.from : platformId, + timestamp: new Date().toISOString(), + }; +} + +export async function pushFormResponseToBridge( + config: GatewayConfig, + mailbox: GatewayRuntimeMailbox, + ctx: FormResponseContext, +): Promise { + await mailbox.deliverFormResponse(buildFormResponsePayload(config, ctx)); +} diff --git a/packages/agent-xmpp/gateway/src/embedded-gateway.ts b/packages/agent-xmpp/gateway/src/embedded-gateway.ts new file mode 100644 index 000000000..5daf9e964 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/embedded-gateway.ts @@ -0,0 +1,427 @@ +import { + DEFAULT_PROTOCOL_NAMESPACES, + bareJid, + type AgentXmppNamespaces, + type OutboundDeliverRequest, +} from "@agent-xmpp/protocol"; +import { xml, type Element } from "@xmpp/xml"; + +import { buildGatewayInfo, DISCO_INFO_NS } from "./agent-api-disco.js"; +import type { GatewayConfig } from "./config.js"; +export { loadConfig } from "./config.js"; +export type { GatewayConfig } from "./config.js"; +import { + sendComposingForAgent, + sendInactiveForAgent, + sendPausedForAgent, +} from "./agent-send.js"; +import { StanzaRouter, type ResolveVirtualAgentFn } from "./stanza-router.js"; +import type { GatewayRuntimeMailbox } from "./runtime-mailbox.js"; +import { applyStoreHints, buildOutboundStanza } from "./xep-plugins/message.js"; +import { isMucJid } from "./xep-plugins/muc.js"; +import { buildTaskEvent, type TaskWireEvent } from "./task-stanza-codec.js"; +import { + createComponentSession, + type IqGetHandler, + type IqRequestOptions, + type XmppComponentSession, +} from "./xmpp-component.js"; +import { RECEIPTS_NS } from "./xep-plugins/receipts.js"; +import { ReceiptTracker } from "./receipt-tracker.js"; +import { PING_NS } from "./xep-plugins/ping.js"; +import { XmppKeepalive } from "./xmpp-keepalive.js"; +import { + buildAvailablePresence, + buildUnavailablePresence, + type VirtualAgentIdentity, +} from "./xep-plugins/presence.js"; + +export interface EmbeddedIqHandlerOptions { + componentJid: string; + protocolNamespaces?: AgentXmppNamespaces; +} + +export interface PresenceSubscription { + agentJid: string; + subscriberJid: string; +} + +export interface PresenceSubscriptionStore { + listPresenceSubscriptions(): PresenceSubscription[]; + setPresenceSubscription( + agentJid: string, + subscriberJid: string, + subscribed: boolean, + ): void; +} + +export type XmppComponentSessionFactory = ( + config: GatewayConfig, + onIqGet?: IqGetHandler, +) => XmppComponentSession; + +export interface EmbeddedXmppGatewayDependencies { + onIqGet?: IqGetHandler; + resolveVirtualAgent?: ResolveVirtualAgentFn; + presenceStore?: PresenceSubscriptionStore; + componentSessionFactory?: XmppComponentSessionFactory; +} + +function presenceRouteKey(route: PresenceSubscription): string { + return `${bareJid(route.agentJid).toLowerCase()}\u0000${bareJid(route.subscriberJid).toLowerCase()}`; +} + +class InMemoryPresenceSubscriptionStore implements PresenceSubscriptionStore { + private readonly subscriptions = new Map(); + + listPresenceSubscriptions(): PresenceSubscription[] { + return [...this.subscriptions.values()]; + } + + setPresenceSubscription( + agentJid: string, + subscriberJid: string, + subscribed: boolean, + ): void { + const route = { + agentJid: bareJid(agentJid), + subscriberJid: bareJid(subscriberJid), + }; + const key = presenceRouteKey(route); + if (subscribed) this.subscriptions.set(key, route); + else this.subscriptions.delete(key); + } +} + +/** + * Root service identity belongs to the reusable gateway itself. Host handlers + * extend this surface with directory, endpoint, task, and administrative IQs. + */ +export function createEmbeddedIqHandler( + options: EmbeddedIqHandlerOptions, + downstream?: IqGetHandler, +): IqGetHandler { + return async (stanza) => { + const to = bareJid(String(stanza.attrs.to ?? "")); + const info = stanza.getChild("query", DISCO_INFO_NS); + if ( + stanza.attrs.type === "get" && + to === bareJid(options.componentJid) && + info && + !info.attrs.node + ) { + return buildGatewayInfo( + stanza, + options.componentJid, + options.protocolNamespaces ?? DEFAULT_PROTOCOL_NAMESPACES, + ); + } + return (await downstream?.(stanza)) ?? null; + }; +} + +/** In-process XMPP channel runtime. All agent IO crosses GatewayRuntimeMailbox. */ +export class EmbeddedXmppGateway { + private session: XmppComponentSession | null = null; + private router: StanzaRouter | null = null; + private readonly receipts: ReceiptTracker; + private sweepTimer: ReturnType | null = null; + private keepalive: XmppKeepalive | null = null; + private connectionState: ReturnType = + "offline"; + private readonly presenceStore: PresenceSubscriptionStore; + private readonly publishedPresence = new Map< + string, + { agent: VirtualAgentIdentity; subscriberJid: string } + >(); + private presenceSync: Promise = Promise.resolve(); + + constructor( + private readonly config: GatewayConfig, + private readonly mailbox: GatewayRuntimeMailbox, + private readonly dependencies: EmbeddedXmppGatewayDependencies = {}, + ) { + this.presenceStore = + dependencies.presenceStore ?? new InMemoryPresenceSubscriptionStore(); + this.receipts = new ReceiptTracker({ + timeoutMs: config.receiptTimeoutMs, + maxResends: config.receiptMaxResends, + }); + } + + async start(): Promise { + if (this.session) return; + const createSession = + this.dependencies.componentSessionFactory ?? createComponentSession; + const session = createSession( + this.config, + createEmbeddedIqHandler(this.config, this.dependencies.onIqGet), + ); + const sendForAgent = async (_agentJid: string, stanza: Element) => + session.send(stanza); + const router = new StanzaRouter( + this.config, + this.mailbox, + sendForAgent, + this.dependencies.resolveVirtualAgent, + (id) => this.receipts.ack(id), + (agent, subscriberJid, subscribed) => + this.updatePresenceSubscription(agent, subscriberJid, subscribed), + ); + session.onStanza((stanza) => void router.handleIncoming(stanza)); + session.onStateChange((state) => { + const wasOnline = this.connectionState === "online"; + this.connectionState = state; + if (state === "online" && !wasOnline) { + this.publishedPresence.clear(); + void this.syncPresence(session); + } + }); + this.session = session; + this.router = router; + await session.start(); + this.connectionState = session.getState(); + this.keepalive = new XmppKeepalive( + { + intervalMs: this.config.pingIntervalMs, + failureThreshold: this.config.pingFailureThreshold, + }, + { + getState: () => session.getState(), + getLastActivityAt: () => session.getLastActivityAt(), + ping: async () => { + await session.requestIq( + xml( + "iq", + { + type: "get", + from: this.config.componentJid, + to: this.config.serverDomain, + }, + xml("ping", { xmlns: PING_NS }), + ), + { timeoutMs: this.config.pingTimeoutMs }, + ); + }, + forceReconnect: (reason) => session.forceReconnect(reason), + }, + ); + this.keepalive.start(); + this.sweepTimer = setInterval( + () => this.resendUnacked(), + this.config.receiptSweepMs, + ); + this.sweepTimer.unref?.(); + } + + async stop(): Promise { + const session = this.session; + this.connectionState = "stopping"; + if (session) await this.publishUnavailablePresence(session); + this.session = null; + this.router = null; + this.keepalive?.stop(); + this.keepalive = null; + if (this.sweepTimer) { + clearInterval(this.sweepTimer); + this.sweepTimer = null; + } + // Drop pending receipts so a restart's sweep can't resend this session's stanzas. + this.receipts.clear(); + if (session) await session.stop(); + this.publishedPresence.clear(); + this.connectionState = "offline"; + } + + private updatePresenceSubscription( + agent: VirtualAgentIdentity, + subscriberJid: string, + subscribed: boolean, + ): void { + const route = { + agentJid: bareJid(agent.jid), + subscriberJid: bareJid(subscriberJid), + }; + this.presenceStore.setPresenceSubscription( + route.agentJid, + route.subscriberJid, + subscribed, + ); + void this.syncPresence(); + } + + syncPresence(session = this.session): Promise { + if (!session || session.getState() !== "online") return Promise.resolve(); + const run = this.presenceSync + .catch(() => undefined) + .then(() => this.reconcilePresence(session)) + .catch((err) => { + console.error("[xmpp-gateway] presence synchronization failed:", err); + }); + this.presenceSync = run; + return run; + } + + private async reconcilePresence( + session: XmppComponentSession, + ): Promise { + const desired = new Map< + string, + { agent: VirtualAgentIdentity; subscriberJid: string } + >(); + for (const subscription of this.presenceStore.listPresenceSubscriptions()) { + const agent = this.dependencies.resolveVirtualAgent?.( + bareJid(subscription.agentJid), + ); + if (!agent) continue; + const route = { + agent, + subscriberJid: bareJid(subscription.subscriberJid), + }; + desired.set(presenceRouteKey(subscription), route); + } + + for (const [key, route] of this.publishedPresence) { + if (desired.has(key)) continue; + await session.send( + buildUnavailablePresence(route.agent, route.subscriberJid), + ); + this.publishedPresence.delete(key); + } + for (const [key, route] of desired) { + if (this.publishedPresence.has(key)) continue; + await session.send( + buildAvailablePresence(route.agent, route.subscriberJid), + ); + this.publishedPresence.set(key, route); + } + } + + private async publishUnavailablePresence( + session: XmppComponentSession, + ): Promise { + for (const route of this.publishedPresence.values()) { + await session + .send(buildUnavailablePresence(route.agent, route.subscriberJid)) + .catch((err) => { + console.error( + "[xmpp-gateway] unavailable presence send failed:", + err, + ); + }); + } + } + + /** + * XEP-0184 sweep. Default is observe-only (receiptMaxResends=0): un-acked messages + * simply expire from tracking, since a missing receipt does not mean the message failed + * and blind resends would duplicate ordinary messages. When an operator opts into + * resends, we retry up to the cap and log the ones that still go unconfirmed. + */ + private resendUnacked(): void { + const session = this.session; + if (!session || !this.isConnected()) return; + const { resend, gaveUp } = this.receipts.due(Date.now()); + for (const stanza of resend) { + void session.send(stanza).catch((err) => { + console.error("[xmpp-gateway] receipt resend failed:", err); + }); + } + // Only noteworthy when resends were actually attempted; observe-only expiry is normal. + if (this.config.receiptMaxResends > 0) { + for (const id of gaveUp) { + console.error( + `[xmpp-gateway] no delivery receipt for ${id} after ${this.config.receiptMaxResends} resends; giving up`, + ); + } + } + } + + isConnected(): boolean { + return this.session !== null && this.connectionState === "online"; + } + + /** Send an IQ get/set and await its correlated result or error response. */ + requestIq(stanza: Element, options?: IqRequestOptions): Promise { + return this.requiredSession().requestIq(stanza, options); + } + + /** + * The single outbound send path. Any stanza carrying an XEP-0184 is + * registered for receipt tracking *before* the send resolves — otherwise a fast peer's + * could arrive before registration and be dropped, leaving a delivered + * message pending (and, with resends enabled, later duplicated). If the send itself + * fails, the entry is removed. + */ + private async sendTracked(stanza: Element): Promise { + const session = this.requiredSession(); + const id = String(stanza.attrs.id ?? ""); + const track = id !== "" && stanza.getChild("request", RECEIPTS_NS) != null; + if (track) this.receipts.register(id, stanza); + try { + await session.send(stanza); + } catch (err) { + if (track) this.receipts.ack(id); + throw err; + } + return id; + } + + async deliver( + input: OutboundDeliverRequest & { from: string }, + ): Promise { + const built = buildOutboundStanza( + { ...input, lang: input.lang ?? this.config.xmlLang }, + input.from, + ); + // XEP-0334 so an offline 1:1 peer still gets it; MUC messages aren't stored. + const stanza = applyStoreHints( + built, + built.attrs.type === "chat" ? { store: true } : undefined, + ); + return this.sendTracked(stanza); + } + + async deliverTaskEvent(event: TaskWireEvent): Promise { + return this.sendTracked( + buildTaskEvent( + event, + this.config.protocolNamespaces ?? DEFAULT_PROTOCOL_NAMESPACES, + ), + ); + } + + async setTyping( + from: string, + to: string, + threadId: string | null, + state: "composing" | "paused" | "inactive", + ): Promise { + const session = this.requiredSession(); + const targets = { to, threadId, groupchat: isMucJid(to) }; + const send = (stanza: Element) => session.send(stanza); + if (state === "inactive") await sendInactiveForAgent(send, from, targets); + else if (state === "paused") await sendPausedForAgent(send, from, targets); + else await sendComposingForAgent(send, from, targets); + } + + private requiredSession(): XmppComponentSession { + if (!this.session) throw new Error("XMPP gateway is not connected"); + return this.session; + } +} + +export type { GatewayRuntimeMailbox } from "./runtime-mailbox.js"; +export { xml, type Element } from "@xmpp/xml"; +export { IqResponseError } from "./xmpp-component.js"; +export type { IqRequestOptions } from "./xmpp-component.js"; +export * from "./agent-api-disco.js"; +export * from "./task-stanza-codec.js"; +export * from "./hash-codec.js"; +export * from "./json-codec.js"; +export * from "./rsm-codec.js"; +export * from "./protocol-error.js"; +export * from "./xep-plugins/ping.js"; +export * from "./xep-plugins/presence.js"; +export * from "./xep-plugins/search.js"; +export * from "./xep-plugins/vcard.js"; diff --git a/packages/agent-xmpp/gateway/src/hash-codec.ts b/packages/agent-xmpp/gateway/src/hash-codec.ts new file mode 100644 index 000000000..505de73f8 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/hash-codec.ts @@ -0,0 +1,23 @@ +import { HASHES_NS } from "@agent-xmpp/protocol"; +import { xml, type Element } from "@xmpp/xml"; + +export interface Sha256Hash { + algorithm: "sha-256"; + value: string; +} + +export function buildHash(value: string): Element { + return xml("hash", { xmlns: HASHES_NS, algo: "sha-256" }, value); +} + +export function parseHash(parent: Element, wrapper?: string): Sha256Hash { + const container = wrapper ? parent.getChild(wrapper) : parent; + const hash = container?.getChild("hash", HASHES_NS); + if (!hash || hash.attrs.algo !== "sha-256") + throw new Error("a sha-256 XEP-0300 hash is required"); + const value = hash.getText(); + if (!/^(?:[A-Za-z0-9+/]{4}){10}[A-Za-z0-9+/]{3}=$/.test(value)) { + throw new Error("invalid SHA-256 Base64 hash"); + } + return { algorithm: "sha-256", value }; +} diff --git a/packages/agent-xmpp/gateway/src/json-codec.ts b/packages/agent-xmpp/gateway/src/json-codec.ts new file mode 100644 index 000000000..826443fd0 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/json-codec.ts @@ -0,0 +1,24 @@ +import { JSON_MEDIA_TYPE, parseStrictJson } from "@agent-xmpp/protocol"; +import { xml, type Element } from "@xmpp/xml"; + +export function parseJsonElement( + element: Element, + maxBytes = 1_048_576, +): unknown { + const mediaType = String(element.attrs["media-type"] ?? ""); + if (mediaType && mediaType !== JSON_MEDIA_TYPE) + throw new Error(`unsupported JSON media type: ${mediaType}`); + return parseStrictJson(element.getText(), { maxBytes }); +} + +export function jsonElement( + name: string, + namespace: string, + canonicalJson: string, +): Element { + return xml( + name, + { xmlns: namespace, "media-type": JSON_MEDIA_TYPE }, + canonicalJson, + ); +} diff --git a/packages/agent-xmpp/gateway/src/protocol-error.ts b/packages/agent-xmpp/gateway/src/protocol-error.ts new file mode 100644 index 000000000..dfe8c9dbe --- /dev/null +++ b/packages/agent-xmpp/gateway/src/protocol-error.ts @@ -0,0 +1,82 @@ +import { xml, type Element } from "@xmpp/xml"; + +export type StanzaErrorCondition = + | "bad-request" + | "forbidden" + | "item-not-found" + | "not-acceptable" + | "conflict" + | "resource-constraint" + | "service-unavailable" + | "unexpected-request" + | "internal-server-error"; + +const STANZA_ERRORS_NS = "urn:ietf:params:xml:ns:xmpp-stanzas"; + +export class ProtocolError extends Error { + constructor( + readonly condition: StanzaErrorCondition, + message: string, + readonly type: "cancel" | "modify" | "auth" | "wait" = stanzaErrorType( + condition, + ), + ) { + super(message); + } +} + +export function protocolErrorIq(request: Element, error: unknown): Element { + const protocolError = + error instanceof ProtocolError + ? error + : new ProtocolError("internal-server-error", "Request processing failed"); + const echoRequestPayload = + protocolError.condition !== "bad-request" && + protocolError.condition !== "resource-constraint" && + protocolError.condition !== "internal-server-error"; + return xml( + "iq", + { + type: "error", + id: request.attrs.id, + from: request.attrs.to, + to: request.attrs.from, + }, + ...(echoRequestPayload ? request.children : []), + xml( + "error", + { type: protocolError.type }, + xml(protocolError.condition, { xmlns: STANZA_ERRORS_NS }), + xml("text", { xmlns: STANZA_ERRORS_NS }, safeMessage(protocolError)), + ), + ); +} + +export function hiddenObjectError(): ProtocolError { + return new ProtocolError( + "item-not-found", + "The requested object was not found", + ); +} + +function safeMessage(error: ProtocolError): string { + if (error.condition === "item-not-found") + return "The requested object was not found"; + if (error.condition === "internal-server-error") + return "Request processing failed"; + return error.message.slice(0, 512); +} + +function stanzaErrorType( + condition: StanzaErrorCondition, +): "cancel" | "modify" | "auth" | "wait" { + if (condition === "bad-request" || condition === "not-acceptable") + return "modify"; + if (condition === "forbidden") return "auth"; + if ( + condition === "resource-constraint" || + condition === "service-unavailable" + ) + return "wait"; + return "cancel"; +} diff --git a/packages/agent-xmpp/gateway/src/receipt-tracker.ts b/packages/agent-xmpp/gateway/src/receipt-tracker.ts new file mode 100644 index 000000000..093b6c574 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/receipt-tracker.ts @@ -0,0 +1,79 @@ +/** + * XEP-0184 outbound delivery-receipt tracking. + * + * A component send only tells us the server accepted the stanza, not that the peer + * received it. For 1:1 messages that carry a , we register the stanza here; + * when the peer returns we `ack` it, and messages left un-acked past the + * timeout are handed back by `due` for a bounded number of resends. Resends reuse the + * same stanza (same id + origin-id), so conformant peers dedup per XEP-0184 §8. + * + * Pure and synchronous — no timers, no IO — so it unit-tests without a live connection. + * + * @see https://xmpp.org/extensions/xep-0184.html + */ +import type { Element } from "@xmpp/xml"; + +interface PendingReceipt { + stanza: Element; + sentAt: number; + attempts: number; +} + +export interface ReceiptTrackerOptions { + timeoutMs: number; + maxResends: number; +} + +/** What a sweep produced: stanzas to resend now, and ids we've given up on. */ +export interface ReceiptSweep { + resend: Element[]; + gaveUp: string[]; +} + +export class ReceiptTracker { + private readonly pending = new Map(); + + constructor(private readonly options: ReceiptTrackerOptions) {} + + /** Record a receipt-requested send keyed by its stanza id. */ + register(id: string, stanza: Element, now: number = Date.now()): void { + if (!id) return; + this.pending.set(id, { stanza, sentAt: now, attempts: 0 }); + } + + /** Peer confirmed delivery of `id`; stop tracking it. */ + ack(id: string): void { + this.pending.delete(id); + } + + /** Drop all pending state — called on gateway stop so a restart can't resend a prior session's stanzas. */ + clear(): void { + this.pending.clear(); + } + + /** + * Entries whose timeout has elapsed: each still under the resend cap is re-armed and + * returned in `resend`; each at the cap is dropped and returned in `gaveUp`. + */ + due(now: number = Date.now()): ReceiptSweep { + const resend: Element[] = []; + const gaveUp: string[] = []; + for (const [id, entry] of this.pending) { + if (now - entry.sentAt < this.options.timeoutMs) continue; + if (entry.attempts >= this.options.maxResends) { + this.pending.delete(id); + gaveUp.push(id); + continue; + } + entry.attempts += 1; + entry.sentAt = now; + resend.push(entry.stanza); + } + return { resend, gaveUp }; + } + + /** Number of messages still awaiting a receipt (for tests / diagnostics). */ + get size(): number { + return this.pending.size; + } +} diff --git a/packages/agent-xmpp/gateway/src/review-fixes.test.ts b/packages/agent-xmpp/gateway/src/review-fixes.test.ts new file mode 100644 index 000000000..4126e9998 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/review-fixes.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "bun:test"; +import { AGENT_API_NS, AGENT_TASK_NS } from "@agent-xmpp/protocol"; +import { xml } from "@xmpp/xml"; + +import { parseManifestRegistration } from "./agent-api-disco.js"; +import { ProtocolError } from "./protocol-error.js"; +import { + buildAcceptedResult, + parseAcceptedResult, + parseTaskEvent, + parseTaskResult, +} from "./task-stanza-codec.js"; + +const requestId = "request-identifier-0001"; +const taskId = "task-identifier-000001"; +const eventId = "event-identifier-00001"; + +describe("review regressions", () => { + it("preserves the current revision in replay acceptance results", () => { + const request = xml("iq", { + type: "set", + id: "invoke", + from: "caller@example.test", + }); + const response = buildAcceptedResult( + request, + { + requestId, + taskId, + revision: 4, + created: "2026-08-27T18:00:00.000Z", + retainUntil: "2026-08-28T18:00:00.000Z", + }, + "assistant@agents.example.test", + ); + + expect(parseAcceptedResult(response)?.revision).toBe(4); + }); + + it("rejects invalid and foreign registration manifests", () => { + const invalid = xml( + "iq", + { type: "set" }, + xml( + "register", + { xmlns: AGENT_API_NS }, + xml( + "manifest", + { xmlns: AGENT_API_NS, "media-type": "application/json" }, + "{}", + ), + ), + ); + const foreign = xml( + "iq", + { type: "set" }, + xml( + "register", + { xmlns: AGENT_API_NS }, + xml( + "manifest", + { xmlns: "urn:example:foreign", "media-type": "application/json" }, + "{}", + ), + ), + ); + + expect(() => parseManifestRegistration(invalid)).toThrow(ProtocolError); + expect(parseManifestRegistration(foreign)).toBeNull(); + }); + + it("rejects non-object task result and event payloads", () => { + const result = xml( + "iq", + { type: "result" }, + xml( + "task-result", + { + xmlns: AGENT_TASK_NS, + "task-id": taskId, + state: "completed", + revision: "1", + "media-type": "application/json", + }, + "null", + ), + ); + const event = xml( + "message", + { + type: "normal", + from: "assistant@agents.example.test", + to: "caller@example.test", + }, + xml( + "event", + { + xmlns: AGENT_TASK_NS, + "task-id": taskId, + "event-id": eventId, + revision: "1", + type: "status", + }, + "[]", + ), + ); + + expect(() => parseTaskResult(result)).toThrow( + "invalid task-result payload", + ); + expect(() => parseTaskEvent(event)).toThrow("invalid task event payload"); + }); +}); diff --git a/packages/agent-xmpp/gateway/src/rsm-codec.ts b/packages/agent-xmpp/gateway/src/rsm-codec.ts new file mode 100644 index 000000000..32c9b3dca --- /dev/null +++ b/packages/agent-xmpp/gateway/src/rsm-codec.ts @@ -0,0 +1,78 @@ +import { RSM_NS } from "@agent-xmpp/protocol"; +import { xml, type Element } from "@xmpp/xml"; + +export interface RsmRequest { + max: number; + after?: string; + before?: string; +} + +export interface RsmPage { + items: T[]; + first?: string; + last?: string; + count: number; +} + +type RsmOrderKey = string | Uint8Array; + +export function parseRsm( + parent: Element, + defaultMax = 100, + maximum = 100, +): RsmRequest { + const set = parent.getChild("set", RSM_NS); + const requested = Number(set?.getChildText("max") ?? defaultMax); + const max = + Number.isInteger(requested) && requested >= 0 + ? Math.min(requested, maximum) + : defaultMax; + return { + max, + after: set?.getChildText("after") ?? undefined, + before: set?.getChildText("before") ?? undefined, + }; +} + +export function pageRsm( + items: T[], + id: (item: T) => string, + request: RsmRequest, + orderKey: (item: T) => RsmOrderKey = id, +): RsmPage { + const ordered = [...items].sort((left, right) => + Buffer.compare(Buffer.from(orderKey(left)), Buffer.from(orderKey(right))), + ); + let start = request.after + ? ordered.findIndex((item) => id(item) === request.after) + 1 + : 0; + if (request.after && start === 0) start = ordered.length; + let end = ordered.length; + if (request.before !== undefined) { + const before = + request.before === "" + ? ordered.length + : ordered.findIndex((item) => id(item) === request.before); + end = before < 0 ? 0 : before; + start = Math.max(0, end - request.max); + } else { + end = Math.min(end, start + request.max); + } + const page = ordered.slice(start, end); + return { + items: page, + first: page[0] ? id(page[0]) : undefined, + last: page.at(-1) ? id(page.at(-1)!) : undefined, + count: ordered.length, + }; +} + +export function buildRsm(page: RsmPage): Element { + return xml( + "set", + { xmlns: RSM_NS }, + ...(page.first ? [xml("first", {}, page.first)] : []), + ...(page.last ? [xml("last", {}, page.last)] : []), + xml("count", {}, String(page.count)), + ); +} diff --git a/packages/agent-xmpp/gateway/src/runtime-mailbox.ts b/packages/agent-xmpp/gateway/src/runtime-mailbox.ts new file mode 100644 index 000000000..2143e9a75 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/runtime-mailbox.ts @@ -0,0 +1,18 @@ +import type { + BridgeFormResponsePayload, + BridgeInboundPayload, +} from "@agent-xmpp/protocol"; +import type { TaskWireEvent } from "./task-stanza-codec.js"; + +/** + * Last-mile transport between the XMPP gateway and an agent runtime. + * + * NanoClaw implements this with per-session inbound.db writes. The interface + * intentionally contains no HTTP or provider concepts so another mailbox can + * replace it later without changing XMPP routing. + */ +export interface GatewayRuntimeMailbox { + deliverInbound(payload: BridgeInboundPayload): Promise; + deliverFormResponse(payload: BridgeFormResponsePayload): Promise; + deliverTaskEvent(event: TaskWireEvent): Promise; +} diff --git a/packages/agent-xmpp/gateway/src/stanza-router.ts b/packages/agent-xmpp/gateway/src/stanza-router.ts new file mode 100644 index 000000000..0551d9468 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/stanza-router.ts @@ -0,0 +1,203 @@ +/** + * Central inbound stanza dispatch for the component. Routes by stanza kind and spec: + * presence -> RFC 6121 §3 roster/probe handling (presence.ts) + * ask-question submit -> XEP-0004 Data Forms (data-form.ts) + * agent-task payloads -> configured gateway-private task namespace (task-stanza-codec.ts) + * XEP-0085/0184/0333 -> chat states & receipts are swallowed, not delivered (receipts.ts) + * message -> normalized to AgentMessage (message.ts), then delivery-gated + * + * On accepted 1:1 messages the router emits an XEP-0085 composing state and, when the + * sender opted in with an XEP-0184 , a delivery receipt. + * + * @see https://www.rfc-editor.org/rfc/rfc6121#section-3 + * @see https://xmpp.org/extensions/xep-0085.html + * @see https://xmpp.org/extensions/xep-0184.html + */ +import type { Element } from "@xmpp/xml"; + +import { + DEFAULT_PROTOCOL_NAMESPACES, + type AgentMessage, +} from "@agent-xmpp/protocol"; + +import { sendComposingForAgent } from "./agent-send.js"; +import { bareJid } from "./xep-plugins/jid.js"; +import type { GatewayConfig } from "./config.js"; +import { + pushFormResponseToBridge, + pushInboundToBridge, + resolveInboundChatTargets, + shouldAcceptStanza, + type InboundDeliveryContext, +} from "./delivery.js"; +import type { GatewayRuntimeMailbox } from "./runtime-mailbox.js"; +import { + isAgentJid, + resolveTargetAgentJid, + stanzaToAgentMessage, +} from "./xep-plugins/message.js"; +import { parseAskQuestionSubmit } from "./xep-plugins/data-form.js"; +import { + buildReceivedReceipt, + isAckOrReceiptStanza, + receivedReceiptId, + requestsReceipt, +} from "./xep-plugins/receipts.js"; +import { parseTaskEvent } from "./task-stanza-codec.js"; +import { + handleVirtualAgentPresence, + type VirtualAgentIdentity, +} from "./xep-plugins/presence.js"; + +export type SendStanzaFn = (stanza: Element) => Promise; +export type SendForAgentFn = ( + agentJid: string, + stanza: Element, +) => Promise; +export type ResolveVirtualAgentFn = ( + jid: string, +) => VirtualAgentIdentity | null; +export type UpdatePresenceSubscriptionFn = ( + agent: VirtualAgentIdentity, + subscriberJid: string, + subscribed: boolean, +) => void; + +export class StanzaRouter { + constructor( + private config: GatewayConfig, + private mailbox: GatewayRuntimeMailbox, + private sendForAgent: SendForAgentFn, + private resolveVirtualAgent?: ResolveVirtualAgentFn, + private onReceipt?: (ackedId: string) => void, + private updatePresenceSubscription?: UpdatePresenceSubscriptionFn, + ) {} + + async handleIncoming(stanza: Element): Promise { + if (stanza.name === "presence") { + const to = bareJid(String(stanza.attrs.to ?? "")); + const agent = this.resolveVirtualAgent?.(to); + if (agent) { + const result = handleVirtualAgentPresence(stanza, agent); + const change = result.subscriptionChange; + if (change) + this.updatePresenceSubscription?.( + agent, + change.subscriberJid, + change.subscribed, + ); + for (const response of result.responses) { + await this.sendForAgent(agent.jid, response); + } + } + return; + } + if (stanza.name !== "message") return; + + const toBare = bareJid(String(stanza.attrs.to ?? "")); + // Stanzas arrive on the component JID; resolve which registered agent they target. + const agentJid = resolveTargetAgentJid( + toBare, + this.config.agentDomain, + this.config.defaultAgentJid, + ); + + if ( + !isAgentJid(agentJid, this.config.agentDomain) && + agentJid !== this.config.defaultAgentJid + ) { + return; + } + + const from = stanza.attrs.from as string; + const fromBare = bareJid(from); + const agentBare = bareJid(agentJid); + // C2S inbox receives agent self-sent stanzas (outbound loopback) — drop them. + if (fromBare && agentBare && fromBare === agentBare) return; + const namespaces = + this.config.protocolNamespaces ?? DEFAULT_PROTOCOL_NAMESPACES; + if (stanza.getChildren("event", namespaces.task).length > 0) { + try { + const taskEvent = parseTaskEvent(stanza, namespaces); + if (taskEvent) await this.mailbox.deliverTaskEvent(taskEvent); + } catch (err) { + console.error( + "[xmpp-gateway] invalid task lifecycle event:", + err instanceof Error ? err.message : err, + ); + } + return; + } + + const formSubmit = parseAskQuestionSubmit(stanza); + if (formSubmit) { + const type = (stanza.attrs.type as string) || "chat"; + await pushFormResponseToBridge(this.config, this.mailbox, { + agentJid, + from, + stanzaType: type, + questionId: formSubmit.questionId, + selectedIndex: formSubmit.selectedIndex, + }); + return; + } + + if (isAckOrReceiptStanza(stanza)) { + // XEP-0184: a peer's confirms one of our outbound messages. + const acked = receivedReceiptId(stanza); + if (acked) this.onReceipt?.(acked); + return; + } + const agentMsg = stanzaToAgentMessage(stanza, this.config.agentDomain); + if (!agentMsg) return; + + const type = (stanza.attrs.type as string) || "chat"; + const agentNick = agentJid.split("@")[0]; + const bodyText = + typeof agentMsg.body === "string" + ? agentMsg.body + : JSON.stringify(agentMsg.body); + + if (!shouldAcceptStanza(type, from, bodyText, agentNick)) return; + + const stanzaId = agentMsg.id; + + const ctx: InboundDeliveryContext = { + agentMsg, + agentJid, + deliveryId: stanzaId, + stanzaType: type, + from, + redelivered: false, + }; + + void sendComposingForAgent( + (stanza) => this.sendForAgent(agentJid, stanza), + agentJid, + resolveInboundChatTargets(from, type, agentMsg), + ).catch((err) => { + console.error("[xmpp-gateway] composing notification send failed:", err); + }); + + try { + await pushInboundToBridge(this.config, this.mailbox, ctx); + } catch (err) { + console.error( + "[xmpp-gateway] inbound delivery failed:", + err instanceof Error ? err.message : err, + ); + return; + } + + // XEP-0184: ack only 1:1 messages that explicitly requested a receipt. + // Groupchat receipts are not used (§5.5) and unsolicited ones spam the sender. + if (from && type === "chat" && requestsReceipt(stanza)) { + await this.sendForAgent( + agentJid, + buildReceivedReceipt(from, agentJid, stanzaId), + ).catch((err) => { + console.error("[xmpp-gateway] received receipt send failed:", err); + }); + } + } +} diff --git a/packages/agent-xmpp/gateway/src/task-stanza-codec.ts b/packages/agent-xmpp/gateway/src/task-stanza-codec.ts new file mode 100644 index 000000000..d8d74fcb1 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/task-stanza-codec.ts @@ -0,0 +1,772 @@ +import { + DEFAULT_PROTOCOL_NAMESPACES, + JSON_MEDIA_TYPE, + bareJid, + isApiVersion, + isNormalizedEndpointJid, + isOpaqueIdentifier, + isToolName, + isXep0082DateTime, + parseStrictJson, + taskEventTypes, + taskStates, + terminalTaskStates, + type AgentTaskEventType, + type AgentTaskRecord, + type AgentTaskState, + type AgentXmppNamespaces, + type McpToolResult, + type PendingTaskInput, +} from "@agent-xmpp/protocol"; +import { xml, type Element } from "@xmpp/xml"; + +import { buildHash, parseHash } from "./hash-codec.js"; + +export interface ParsedTaskInvocation { + requestId: string; + tool: string; + apiVersion: string; + manifestHash: string; + callerJid: string; + notificationJid: string; + toJid: string; + arguments: unknown; + deadline?: string; +} + +export interface ParsedTaskRecoveryRequest { + kind: "state" | "result"; + taskId: string; +} + +export interface ParsedTaskCancellation { + taskId: string; + expectedRevision: number; + reason?: string; +} + +export interface ParsedTaskInput { + taskId: string; + requestId: string; + expectedRevision: number; + input: unknown; +} + +export interface TaskWireEvent { + taskId: string; + eventId: string; + revision: number; + type: AgentTaskEventType; + from: string; + to: string; + payload: Record; +} + +export interface AcceptedTask { + requestId: string; + taskId: string; + revision: number; + created: string; + retainUntil: string; +} + +export interface TaskStateSnapshot { + taskId: string; + endpoint: string; + state: AgentTaskState; + revision: number; + apiVersion: string; + manifestHash: string; + created: string; + updated: string; + retainUntil: string; + deadline?: string; + resultAvailable: boolean; + pendingInput?: PendingTaskInput; +} + +export interface TaskResultSnapshot { + taskId: string; + state: Extract; + revision: number; + result?: McpToolResult; + error?: { + code: string; + message: string; + retryable: boolean; + details?: Record; + }; + summary?: string; +} + +export function parseTaskInvocation( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): ParsedTaskInvocation | null { + if (stanza.name !== "iq" || stanza.attrs.type !== "set") return null; + const payloads = stanza.getChildElements(); + const invoke = stanza.getChild("invoke", namespaces.task); + if (!invoke) return null; + if (payloads.length !== 1 || payloads[0] !== invoke) { + throw new Error("invoke must be the only IQ payload"); + } + assertOnlyAttributes(invoke, ["xmlns", "request-id", "tool", "api-version"]); + const children = invoke.getChildElements(); + const expectedNames = + children.length === 3 + ? ["manifest-hash", "arguments", "deadline"] + : ["manifest-hash", "arguments"]; + if ( + children.length < 2 || + children.length > 3 || + children.some( + (child, index) => + child.name !== expectedNames[index] || + child.getNS() !== namespaces.task, + ) + ) { + throw new Error( + "invoke children must be manifest-hash, arguments, then optional deadline", + ); + } + const [manifestHashElement, argumentsElement, deadlineElement] = children; + assertOnlyAttributes(manifestHashElement!, ["xmlns"]); + assertOnlyAttributes(argumentsElement!, ["xmlns", "media-type"]); + if (deadlineElement) assertOnlyAttributes(deadlineElement, ["xmlns"]); + const hashChildren = manifestHashElement!.getChildElements(); + if ( + hashChildren.length !== 1 || + hashChildren[0]!.name !== "hash" || + hashChildren[0]!.getNS() !== namespaces.hashes + ) { + throw new Error("manifest-hash must contain exactly one XEP-0300 hash"); + } + assertOnlyAttributes(hashChildren[0]!, ["xmlns", "algo"]); + if ( + argumentsElement!.getChildElements().length > 0 || + (deadlineElement?.getChildElements().length ?? 0) > 0 + ) { + throw new Error("arguments and deadline must contain character data only"); + } + const requestId = String(invoke.attrs["request-id"] ?? ""); + const tool = String(invoke.attrs.tool ?? ""); + const apiVersion = String(invoke.attrs["api-version"] ?? ""); + const targetJid = String(stanza.attrs.to ?? ""); + const deadline = deadlineElement?.getText(); + if ( + !isOpaqueIdentifier(requestId) || + !isToolName(tool) || + !isApiVersion(apiVersion) || + !isNormalizedEndpointJid(targetJid) || + (deadline !== undefined && !isXep0082DateTime(deadline)) || + argumentsElement!.attrs["media-type"] !== JSON_MEDIA_TYPE + ) { + throw new Error("invoke is missing required attributes or JSON arguments"); + } + return { + requestId, + tool, + apiVersion, + manifestHash: parseHash(invoke, "manifest-hash").value, + callerJid: bareJid(String(stanza.attrs.from ?? "")), + notificationJid: String(stanza.attrs.from ?? ""), + toJid: targetJid, + arguments: parseStrictJson(argumentsElement!.getText()), + deadline, + }; +} + +export function parseTaskRecoveryRequest( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): ParsedTaskRecoveryRequest | null { + const state = stanza.getChild("task-state-request", namespaces.task); + const result = stanza.getChild("task-result-request", namespaces.task); + const payload = state ?? result; + if (!payload) return null; + assertIqRequest(stanza, payload, "get"); + assertOnlyAttributes(payload, ["xmlns", "task-id"]); + assertEmptyElement(payload); + const taskId = String(payload.attrs["task-id"] ?? ""); + if (!isOpaqueId(taskId)) throw new Error("invalid task recovery identifier"); + return { kind: state ? "state" : "result", taskId }; +} + +export function parseTaskCancellation( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): ParsedTaskCancellation | null { + const cancel = stanza.getChild("cancel", namespaces.task); + if (!cancel) return null; + assertIqRequest(stanza, cancel, "set"); + assertOnlyAttributes(cancel, ["xmlns", "task-id", "expected-revision"]); + const children = cancel.getChildElements(); + if ( + children.length > 1 || + children.some( + (child) => child.name !== "reason" || child.getNS() !== namespaces.task, + ) + ) { + throw new Error("cancel may contain only one reason"); + } + if ( + cancel.children.some( + (child) => typeof child === "string" && child.trim() !== "", + ) + ) { + throw new Error("cancel may not contain direct character data"); + } + const reason = children[0]; + if (reason) { + assertOnlyAttributes(reason, ["xmlns"]); + if (reason.getChildElements().length > 0) + throw new Error("reason must contain character data only"); + } + const taskId = String(cancel.attrs["task-id"] ?? ""); + const expectedRevision = parseNonNegativeInteger( + cancel.attrs["expected-revision"], + ); + if (!isOpaqueId(taskId)) throw new Error("invalid cancellation identifier"); + return { + taskId, + expectedRevision, + ...(reason ? { reason: reason.getText() } : {}), + }; +} + +export function parseTaskInput( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): ParsedTaskInput | null { + const provide = stanza.getChild("provide-input", namespaces.task); + if (!provide) return null; + assertIqRequest(stanza, provide, "set"); + assertOnlyAttributes(provide, [ + "xmlns", + "task-id", + "request-id", + "expected-revision", + ]); + const children = provide.getChildElements(); + if ( + children.length !== 1 || + children[0]!.name !== "input" || + children[0]!.getNS() !== namespaces.task || + provide.children.some( + (child) => typeof child === "string" && child.trim() !== "", + ) + ) { + throw new Error("provide-input must contain exactly one input"); + } + const input = children[0]!; + assertOnlyAttributes(input, ["xmlns", "media-type"]); + if ( + input.attrs["media-type"] !== JSON_MEDIA_TYPE || + input.getChildElements().length > 0 + ) { + throw new Error("input must contain JSON character data"); + } + const taskId = String(provide.attrs["task-id"] ?? ""); + const requestId = String(provide.attrs["request-id"] ?? ""); + const expectedRevision = parseNonNegativeInteger( + provide.attrs["expected-revision"], + ); + if (!isOpaqueId(taskId) || !isOpaqueId(requestId)) + throw new Error("invalid task input identifier"); + return { + taskId, + requestId, + expectedRevision, + input: parseStrictJson(input.getText()), + }; +} + +export function buildTaskInvocation( + task: AgentTaskRecord, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return xml( + "iq", + { + from: task.callerJid, + to: task.targetJid, + type: "set", + id: `invoke-${task.requestId}`, + }, + xml( + "invoke", + { + xmlns: namespaces.task, + "request-id": task.requestId, + tool: task.tool, + "api-version": task.apiVersion, + }, + xml("manifest-hash", {}, buildHash(task.manifestHash)), + xml( + "arguments", + { "media-type": JSON_MEDIA_TYPE }, + JSON.stringify(task.arguments), + ), + ...(task.deadline ? [xml("deadline", {}, task.deadline)] : []), + ), + ); +} + +export function buildAcceptedResult( + request: Element, + accepted: AcceptedTask, + from: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return xml( + "iq", + { type: "result", id: request.attrs.id, from, to: request.attrs.from }, + xml("accepted", { + xmlns: namespaces.task, + "request-id": accepted.requestId, + "task-id": accepted.taskId, + revision: String(accepted.revision), + created: accepted.created, + "retain-until": accepted.retainUntil, + }), + ); +} + +export function parseAcceptedResult( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): AcceptedTask | null { + if (stanza.name !== "iq" || stanza.attrs.type !== "result") return null; + const accepted = stanza.getChild("accepted", namespaces.task); + if (!accepted) return null; + const revision = Number(accepted.attrs.revision); + if (!Number.isSafeInteger(revision) || revision < 0) + throw new Error("accepted task has invalid revision"); + const parsed: AcceptedTask = { + requestId: String(accepted.attrs["request-id"] ?? ""), + taskId: String(accepted.attrs["task-id"] ?? ""), + revision, + created: String(accepted.attrs.created ?? ""), + retainUntil: String(accepted.attrs["retain-until"] ?? ""), + }; + if ( + !parsed.requestId || + !parsed.taskId || + !isXep0082DateTime(parsed.created) || + !isXep0082DateTime(parsed.retainUntil) + ) { + throw new Error("accepted task is missing required attributes"); + } + if (!isOpaqueId(parsed.requestId) || !isOpaqueId(parsed.taskId)) { + throw new Error("accepted task contains an invalid opaque identifier"); + } + return parsed; +} + +export function buildTaskStateResponse( + request: Element, + task: AgentTaskRecord, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return xml( + "iq", + { + type: "result", + id: request.attrs.id, + from: task.targetJid, + to: request.attrs.from, + }, + xml( + "task-state", + { + xmlns: namespaces.task, + "task-id": task.taskId, + endpoint: task.targetJid, + state: task.state, + revision: String(task.revision), + "api-version": task.apiVersion, + created: task.createdAt, + updated: task.updatedAt, + "retain-until": task.retainUntil, + "result-available": terminalTaskStates.has(task.state) + ? "true" + : "false", + ...(task.deadline ? { deadline: task.deadline } : {}), + }, + xml( + "manifest-hash", + {}, + xml( + "hash", + { xmlns: namespaces.hashes, algo: "sha-256" }, + task.manifestHash, + ), + ), + ...(task.pendingInput + ? [ + xml( + "pending-input", + { "media-type": JSON_MEDIA_TYPE }, + JSON.stringify(task.pendingInput), + ), + ] + : []), + ), + ); +} + +export function buildTaskResultResponse( + request: Element, + task: AgentTaskRecord, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const payload = + task.state === "completed" + ? { + result: task.result, + ...(task.summary ? { summary: task.summary } : {}), + } + : task.state === "failed" + ? { error: task.error } + : {}; + return xml( + "iq", + { + type: "result", + id: request.attrs.id, + from: task.targetJid, + to: request.attrs.from, + }, + xml( + "task-result", + { + xmlns: namespaces.task, + "task-id": task.taskId, + state: task.state, + revision: String(task.revision), + "media-type": JSON_MEDIA_TYPE, + }, + JSON.stringify(payload), + ), + ); +} + +export function buildTaskStateRequest( + task: AgentTaskRecord, + remoteTaskId: string, +): Element { + return xml( + "iq", + { + from: task.callerJid, + to: task.targetJid, + type: "get", + id: `state-${task.taskId}-${task.revision}`, + }, + xml("task-state-request", { + xmlns: DEFAULT_PROTOCOL_NAMESPACES.task, + "task-id": remoteTaskId, + }), + ); +} + +export function buildTaskResultRequest( + task: AgentTaskRecord, + remoteTaskId: string, +): Element { + return xml( + "iq", + { + from: task.callerJid, + to: task.targetJid, + type: "get", + id: `result-${task.taskId}-${task.revision}`, + }, + xml("task-result-request", { + xmlns: DEFAULT_PROTOCOL_NAMESPACES.task, + "task-id": remoteTaskId, + }), + ); +} + +export function buildTaskCancellation( + task: AgentTaskRecord, + remoteTaskId: string, + expectedRevision: number, + reason?: string, +): Element { + return xml( + "iq", + { + from: task.callerJid, + to: task.targetJid, + type: "set", + id: `cancel-${task.taskId}-${expectedRevision}`, + }, + xml( + "cancel", + { + xmlns: DEFAULT_PROTOCOL_NAMESPACES.task, + "task-id": remoteTaskId, + "expected-revision": String(expectedRevision), + }, + ...(reason !== undefined ? [xml("reason", {}, reason)] : []), + ), + ); +} + +export function buildTaskInput( + task: AgentTaskRecord, + remoteTaskId: string, + requestId: string, + expectedRevision: number, + input: unknown, +): Element { + return xml( + "iq", + { + from: task.callerJid, + to: task.targetJid, + type: "set", + id: `input-${task.taskId}-${expectedRevision}`, + }, + xml( + "provide-input", + { + xmlns: DEFAULT_PROTOCOL_NAMESPACES.task, + "task-id": remoteTaskId, + "request-id": requestId, + "expected-revision": String(expectedRevision), + }, + xml("input", { "media-type": JSON_MEDIA_TYPE }, JSON.stringify(input)), + ), + ); +} + +export function parseTaskActionResult( + stanza: Element, + name: "cancel-accepted" | "input-accepted", +): { taskId: string; revision: number } | null { + if (stanza.name !== "iq" || stanza.attrs.type !== "result") return null; + const accepted = stanza.getChild(name, DEFAULT_PROTOCOL_NAMESPACES.task); + if (!accepted) return null; + const taskId = String(accepted.attrs["task-id"] ?? ""); + const revision = Number(accepted.attrs.revision); + if (!isOpaqueId(taskId) || !Number.isSafeInteger(revision) || revision < 1) { + throw new Error(`invalid ${name} result`); + } + return { taskId, revision }; +} + +export function parseTaskStateResult( + stanza: Element, +): TaskStateSnapshot | null { + if (stanza.name !== "iq" || stanza.attrs.type !== "result") return null; + const state = stanza.getChild("task-state", DEFAULT_PROTOCOL_NAMESPACES.task); + if (!state) return null; + const taskState = String(state.attrs.state ?? "") as AgentTaskState; + const taskId = String(state.attrs["task-id"] ?? ""); + const revision = Number(state.attrs.revision); + const endpoint = String(state.attrs.endpoint ?? ""); + const apiVersion = String(state.attrs["api-version"] ?? ""); + const created = String(state.attrs.created ?? ""); + const updated = String(state.attrs.updated ?? ""); + const retainUntil = String(state.attrs["retain-until"] ?? ""); + if ( + !isOpaqueId(taskId) || + !taskStates.includes(taskState) || + !Number.isSafeInteger(revision) || + revision < 0 || + !isNormalizedEndpointJid(endpoint) || + !isApiVersion(apiVersion) || + !isXep0082DateTime(created) || + !isXep0082DateTime(updated) || + !isXep0082DateTime(retainUntil) || + (state.attrs.deadline !== undefined && + !isXep0082DateTime(String(state.attrs.deadline))) + ) { + throw new Error("invalid task-state result"); + } + const pending = state.getChild("pending-input"); + if (pending && pending.attrs["media-type"] !== JSON_MEDIA_TYPE) { + throw new Error("pending task input must use application/json"); + } + return { + taskId, + endpoint, + state: taskState, + revision, + apiVersion, + manifestHash: parseHash(state, "manifest-hash").value, + created, + updated, + retainUntil, + deadline: state.attrs.deadline ? String(state.attrs.deadline) : undefined, + resultAvailable: state.attrs["result-available"] === "true", + pendingInput: pending + ? (parseStrictJson(pending.getText()) as PendingTaskInput) + : undefined, + }; +} + +export function parseTaskResult(stanza: Element): TaskResultSnapshot | null { + if (stanza.name !== "iq" || stanza.attrs.type !== "result") return null; + const result = stanza.getChild( + "task-result", + DEFAULT_PROTOCOL_NAMESPACES.task, + ); + if (!result) return null; + const taskId = String(result.attrs["task-id"] ?? ""); + const state = String(result.attrs.state ?? "") as TaskResultSnapshot["state"]; + const revision = Number(result.attrs.revision); + if ( + !isOpaqueId(taskId) || + !terminalTaskStates.has(state) || + !Number.isSafeInteger(revision) || + revision < 1 || + result.attrs["media-type"] !== JSON_MEDIA_TYPE + ) { + throw new Error("invalid task-result"); + } + const payload = parseTaskPayload( + result.getText() || "{}", + "invalid task-result payload", + ); + return { + taskId, + state, + revision, + result: payload.result as McpToolResult | undefined, + error: payload.error as TaskResultSnapshot["error"], + summary: typeof payload.summary === "string" ? payload.summary : undefined, + }; +} + +export function parseTaskEvent( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): TaskWireEvent | null { + if (stanza.name !== "message") return null; + const events = stanza.getChildren("event", namespaces.task); + if (events.length === 0) return null; + if (events.length !== 1) + throw new Error("task event message must contain exactly one event"); + const messageType = String(stanza.attrs.type ?? "normal"); + if (messageType !== "normal") + throw new Error("invalid task event message type"); + const event = events[0]!; + assertOnlyAttributes(event, [ + "xmlns", + "task-id", + "event-id", + "revision", + "type", + ]); + if (event.getChildElements().length > 0) + throw new Error("task event payload must contain character data only"); + const type = String(event.attrs.type ?? "") as AgentTaskEventType; + if (!taskEventTypes.includes(type)) { + throw new Error("unknown task event type"); + } + const revision = Number(event.attrs.revision); + if (!Number.isSafeInteger(revision) || revision < 1) + throw new Error("invalid task event revision"); + const taskId = String(event.attrs["task-id"] ?? ""); + const eventId = String(event.attrs["event-id"] ?? ""); + if (!isOpaqueId(taskId) || !isOpaqueId(eventId)) + throw new Error("invalid task event identifier"); + return { + taskId, + eventId, + revision, + type, + from: String(stanza.attrs.from ?? ""), + to: String(stanza.attrs.to ?? ""), + payload: parseTaskPayload( + event.getText() || "{}", + "invalid task event payload", + ), + }; +} + +export function buildTaskEvent( + event: TaskWireEvent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return xml( + "message", + { from: event.from, to: event.to, type: "normal", id: event.eventId }, + xml( + "event", + { + xmlns: namespaces.task, + "task-id": event.taskId, + "event-id": event.eventId, + revision: String(event.revision), + type: event.type, + }, + JSON.stringify(event.payload), + ), + ); +} + +export function isOpaqueId(value: string): boolean { + return isOpaqueIdentifier(value); +} + +function assertIqRequest( + stanza: Element, + payload: Element, + type: "get" | "set", +): void { + if ( + stanza.name !== "iq" || + stanza.attrs.type !== type || + stanza.getChildElements().length !== 1 || + stanza.getChildElements()[0] !== payload + ) { + throw new Error( + `${payload.name} must be the only payload of an IQ ${type}`, + ); + } +} + +function assertEmptyElement(element: Element): void { + if ( + element.getChildElements().length > 0 || + element.children.some( + (child) => typeof child === "string" && child.trim() !== "", + ) + ) { + throw new Error(`${element.name} must be empty`); + } +} + +function parseNonNegativeInteger(value: unknown): number { + if (typeof value !== "string" || !/^\+?\d+$/.test(value)) + throw new Error("missing non-negative integer"); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) + throw new Error("invalid non-negative integer"); + return parsed; +} + +function assertOnlyAttributes( + element: Element, + allowed: readonly string[], +): void { + const allowedNames = new Set(allowed); + if (Object.keys(element.attrs).some((name) => !allowedNames.has(name))) { + throw new Error(`${element.name} has unsupported attributes`); + } +} + +function parseTaskPayload( + text: string, + errorMessage: string, +): Record { + const value = parseStrictJson(text); + if (value === null || Array.isArray(value) || typeof value !== "object") + throw new Error(errorMessage); + return value as Record; +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/chatstate.ts b/packages/agent-xmpp/gateway/src/xep-plugins/chatstate.ts new file mode 100644 index 000000000..6684e78df --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/chatstate.ts @@ -0,0 +1,65 @@ +/** + * XEP-0085 Chat State Notifications. + * @see https://xmpp.org/extensions/xep-0085.html + */ + +import { xml, type Element } from "@xmpp/xml"; + +import { bareJid } from "./jid.js"; + +const CHATSTATES_NS = "http://jabber.org/protocol/chatstates"; + +export function isChatStateStanza(stanza: Element): boolean { + if (stanza.name !== "message") return false; + const body = stanza.getChildText("body"); + if (body?.trim()) return false; + for (const child of stanza.children) { + if (typeof child !== "object" || child === null) continue; + if (child.attrs?.xmlns === CHATSTATES_NS) return true; + } + return false; +} + +export function buildComposingStanza(opts: { + from: string; + to: string; + threadId?: string | null; + groupchat?: boolean; +}): Element { + return buildChatStateStanza({ ...opts, state: "composing" }); +} + +export function buildPausedStanza(opts: { + from: string; + to: string; + threadId?: string | null; + groupchat?: boolean; +}): Element { + return buildChatStateStanza({ ...opts, state: "paused" }); +} + +export function buildInactiveStanza(opts: { + from: string; + to: string; + threadId?: string | null; + groupchat?: boolean; +}): Element { + return buildChatStateStanza({ ...opts, state: "inactive" }); +} + +function buildChatStateStanza(opts: { + from: string; + to: string; + threadId?: string | null; + groupchat?: boolean; + state: "composing" | "paused" | "inactive"; +}): Element { + // XEP-0085 states belong to the same 1:1 resource as the chat response. + const to = opts.groupchat ? bareJid(opts.to) : opts.to; + const type = opts.groupchat ? "groupchat" : "chat"; + const children: Element[] = [xml(opts.state, { xmlns: CHATSTATES_NS })]; + if (opts.threadId) { + children.unshift(xml("thread", {}, opts.threadId)); + } + return xml("message", { type, to, from: bareJid(opts.from) }, ...children); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/data-form.ts b/packages/agent-xmpp/gateway/src/xep-plugins/data-form.ts new file mode 100644 index 000000000..bd2cb0e02 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/data-form.ts @@ -0,0 +1,166 @@ +/** + * XEP-0004 Data Forms — ask_user_question multiple-choice via list-single fields. + * Outbound forms also carry XEP-0359 origin IDs. The optional reply marker uses + * the XEP-0461 namespace including `to`; it always uses `req.inReplyTo` as the + * id regardless of 1:1 vs groupchat context (see message.ts header for detail). + * + * @see https://xmpp.org/extensions/xep-0004.html + * @see https://xmpp.org/extensions/xep-0359.html + * @see https://xmpp.org/extensions/xep-0461.html + */ + +import { xml, type Element } from "@xmpp/xml"; +import { ulid } from "ulid"; + +import type { + AskQuestionPayload, + OutboundDeliverRequest, +} from "@agent-xmpp/protocol"; + +import { bareJid, isMucJid } from "./jid.js"; +import { RECEIPTS_NS } from "./receipts.js"; + +export const DATA_FORM_NS = "jabber:x:data"; +export const ASK_QUESTION_FORM_TYPE = "urn:xmpp:nanoclaw:ask-question:0"; + +const ORIGIN_ID_NS = "urn:xmpp:sid:0"; +const REPLY_NS = "urn:xmpp:reply:0"; + +export interface AskQuestionSubmit { + questionId: string; + selectedIndex: number; +} + +function optionLabel(raw: AskQuestionPayload["options"][number]): string { + return typeof raw === "string" ? raw : raw.label; +} + +function buildBodyFallback(payload: AskQuestionPayload): string { + const labels = payload.options.map(optionLabel); + return `${payload.title}\n\n${payload.question}\n\nOptions: ${labels.join(", ")}`; +} + +function hiddenField(varName: string, value: string): Element { + return xml( + "field", + { var: varName, type: "hidden" }, + xml("value", {}, value), + ); +} + +function listSingleField(payload: AskQuestionPayload): Element { + const options = payload.options.map((raw, idx) => + xml("option", { label: optionLabel(raw) }, xml("value", {}, String(idx))), + ); + return xml( + "field", + { var: "response", type: "list-single", label: "Choose one" }, + ...options, + ); +} + +export function isAskQuestionContent( + content: unknown, +): content is AskQuestionPayload { + if (!content || typeof content !== "object") return false; + const c = content as Record; + return ( + c.type === "ask_question" && + typeof c.questionId === "string" && + typeof c.title === "string" && + typeof c.question === "string" && + Array.isArray(c.options) && + c.options.length > 0 + ); +} + +export function buildAskQuestionFormStanza( + req: OutboundDeliverRequest, + fromJid: string, + payload: AskQuestionPayload, +): Element { + const id = ulid(); + const children: Element[] = [ + xml("body", {}, buildBodyFallback(payload)), + xml( + "x", + { xmlns: DATA_FORM_NS, type: "form" }, + xml("title", {}, payload.title), + xml("instructions", {}, payload.question), + hiddenField("FORM_TYPE", ASK_QUESTION_FORM_TYPE), + hiddenField("questionId", payload.questionId), + listSingleField(payload), + ), + xml("origin-id", { xmlns: ORIGIN_ID_NS, id }), + ]; + + if (req.threadId) { + children.unshift(xml("thread", {}, req.threadId)); + } + + if (req.inReplyTo) { + // XEP-0461: bare JID is only a MAY for 1:1; groupchat wants the full JID. + const isMuc = isMucJid(req.to); + children.push( + xml("reply", { + xmlns: REPLY_NS, + id: req.inReplyTo, + to: isMuc ? req.to : bareJid(req.to), + }), + ); + } + + const isMuc = isMucJid(req.to); + // XEP-0184 §5.1/§5.5: request a delivery receipt on 1:1 forms only (never MUC), so the + // form is tracked and resent like any other chat message sent through deliver(). + if (!isMuc) { + children.push(xml("request", { xmlns: RECEIPTS_NS })); + } + + // RFC 6121 section 8.5.2.1: preserve the initiating resource for 1:1 replies. + const to = req.threadId && isMuc ? req.to : isMuc ? bareJid(req.to) : req.to; + const type = isMuc ? "groupchat" : "chat"; + + return xml( + "message", + { + type, + id, + to, + from: fromJid, + ...(req.lang ? { "xml:lang": req.lang } : {}), + }, + ...children, + ); +} + +function dataFormFieldValue(form: Element, varName: string): string | null { + for (const child of form.children) { + if (typeof child === "string") continue; + if (child.name !== "field" || child.attrs.var !== varName) continue; + const value = child.getChildText("value"); + return value ?? null; + } + return null; +} + +export function parseAskQuestionSubmit( + stanza: Element, +): AskQuestionSubmit | null { + if (stanza.name !== "message") return null; + + const form = stanza.getChild("x", DATA_FORM_NS); + if (!form || form.attrs.type !== "submit") return null; + + const formType = dataFormFieldValue(form, "FORM_TYPE"); + if (formType !== ASK_QUESTION_FORM_TYPE) return null; + + const questionId = dataFormFieldValue(form, "questionId"); + const responseRaw = dataFormFieldValue(form, "response"); + if (!questionId || responseRaw === null) return null; + + const selectedIndex = Number(responseRaw); + if (!Number.isInteger(selectedIndex) || selectedIndex < 0) return null; + + return { questionId, selectedIndex }; +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/jid.ts b/packages/agent-xmpp/gateway/src/xep-plugins/jid.ts new file mode 100644 index 000000000..102790df6 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/jid.ts @@ -0,0 +1,9 @@ +/** Shared JID helpers (kept cycle-free so both message.ts and muc.ts can import it). */ + +/** Bare JID (localpart@domain) — strips any /resource. */ +export { bareJid } from "@agent-xmpp/protocol"; + +/** True for MUC room JIDs on the conventional `conference.` / `groups.` service domains. */ +export function isMucJid(jid: string): boolean { + return jid.includes("@conference.") || jid.includes("@groups."); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/message.ts b/packages/agent-xmpp/gateway/src/xep-plugins/message.ts new file mode 100644 index 000000000..b21d82c78 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/message.ts @@ -0,0 +1,307 @@ +/** + * Message normalization and construction. + * + * The JSON payload nests its content in a + * child per XEP-0335; `datatype` still carries a MIME type rather than a + * schema namespace, and the JSON body is the gateway's own + * kind/contentType/body envelope, not caller-defined XEP-0432 content — + * both are deliberate gateway conventions, not spec violations. The reply + * marker carries `to` per XEP-0461, but always uses `req.inReplyTo` as the + * id regardless of 1:1 vs groupchat context; the spec's groupchat-specific + * stanza-id-selection rule is not implemented. The content-type marker, + * XEP-0334 processing hints, and XEP-0359 origin IDs use their standard + * namespaces. + * + * @see https://xmpp.org/extensions/xep-0432.html + * @see https://xmpp.org/extensions/xep-0481.html + * @see https://xmpp.org/extensions/xep-0461.html + * @see https://xmpp.org/extensions/xep-0334.html + * @see https://xmpp.org/extensions/xep-0359.html + * @see https://xmpp.org/extensions/xep-0335.html + */ + +import { createHash } from "crypto"; + +import { xml, type Element } from "@xmpp/xml"; +import { ulid } from "ulid"; + +import { bareJid, isMucJid } from "./jid.js"; + +import type { + AgentMessage, + InboundMessage, + MessageKind, + MessagePolicy, + OutboundDeliverRequest, + XmppSourceMetadata, +} from "@agent-xmpp/protocol"; + +import { + buildAskQuestionFormStanza, + isAskQuestionContent, +} from "./data-form.js"; +import { RECEIPTS_NS } from "./receipts.js"; + +const JSON_NS = "urn:xmpp:json-msg:0"; +const ORIGIN_ID_NS = "urn:xmpp:sid:0"; +const REPLY_NS = "urn:xmpp:reply:0"; +const STORE_NS = "urn:xmpp:hints"; +const CONTENT_TYPE_NS = "urn:xmpp:content"; + +export function extractStableId(stanza: Element): string { + const attrId = stanza.attrs.id as string | undefined; + if (attrId) return attrId; + const origin = stanza.getChild("origin-id", ORIGIN_ID_NS); + if (origin?.attrs.id) return origin.attrs.id as string; + // No stanza id: derive a deterministic id from content so a redelivered stanza + // dedups instead of being processed twice. ponytail: content hash — two identical + // id-less messages collide; acceptable since servers virtually always stamp `id`. + const from = (stanza.attrs.from as string) || ""; + const to = (stanza.attrs.to as string) || ""; + const body = stanza.getChildText("body") || ""; + const thread = stanza.getChild("thread")?.getText() || ""; + const digest = createHash("sha256") + .update(`${from}\n${to}\n${thread}\n${body}`) + .digest("hex"); + return `derived-${digest.slice(0, 26)}`; +} + +function payloadText(stanza: Element): string | null { + const payload = stanza.getChild("payload", JSON_NS); + if (!payload) return null; + return payload.getChildText("json", "urn:xmpp:json:0") || null; +} + +function parseJsonPayload( + stanza: Element, +): { kind: MessageKind; contentType: string; body: unknown } | null { + const payload = stanza.getChild("payload", JSON_NS); + if (!payload) return null; + const datatype = (payload.attrs.datatype as string) || "application/json"; + const raw = payloadText(stanza); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as { + kind?: MessageKind; + contentType?: string; + body?: unknown; + }; + return { + kind: parsed.kind || "text", + contentType: parsed.contentType || datatype, + body: parsed.body ?? parsed, + }; + // eslint-disable-next-line no-catch-all/no-catch-all -- malformed JSON payload falls back to raw text + } catch { + return { kind: "text", contentType: datatype, body: raw }; + } +} + +function bodyText(stanza: Element): string { + return stanza.getChildText("body") || ""; +} + +export function stanzaToAgentMessage( + stanza: Element, + agentDomain: string, +): AgentMessage | null { + if (stanza.name !== "message") return null; + const type = (stanza.attrs.type as string) || "chat"; + if (type === "error" || type === "headline") return null; + + const from = stanza.attrs.from as string; + const to = stanza.attrs.to as string; + if (!from || !to) return null; + + const id = extractStableId(stanza); + const threadEl = stanza.getChild("thread"); + // XEP-0201: the thread id is the element's text content, not a child or attribute. + const threadId = + threadEl?.getText()?.trim() || (threadEl?.attrs as { id?: string })?.id; + + const replyEl = stanza.getChild("reply", REPLY_NS); + const replyTo = replyEl?.attrs.id as string | undefined; + + const json = parseJsonPayload(stanza); + const text = bodyText(stanza); + const isMuc = type === "groupchat"; + const roomId = isMuc ? bareJid(from) : undefined; + const fromBare = bareJid(from); + + let kind: MessageKind = json?.kind || "text"; + let contentType = json?.contentType || "text/plain"; + let body: unknown = json?.body ?? text; + + const ctEl = stanza.getChild("content", CONTENT_TYPE_NS); + if (ctEl?.attrs.type) contentType = ctEl.attrs.type as string; + + if (!json && text.startsWith("{")) { + try { + const parsed = JSON.parse(text); + if (parsed.kind) kind = parsed.kind; + if (parsed.contentType) contentType = parsed.contentType; + body = parsed.body ?? parsed; + // eslint-disable-next-line no-catch-all/no-catch-all -- body looks like JSON but isn't; keep as plain text + } catch { + /* plain text */ + } + } + + // XEP-0513: MUC mentions MUST address by `occupantid` (XEP-0421) when the room + // supports it; only outside MUC (or in occupant-id-less rooms) is `jid` used. + // Accept either so occupant-id-addressed mentions aren't silently dropped. + const mentions = stanza + .getChildren("mention", "urn:xmpp:mentions:0") + .map((el) => (el.attrs.jid ?? el.attrs.occupantid) as string) + .filter(Boolean); + const extensions: Record = {}; + if (mentions.length) extensions.mentions = mentions; + + return { + id, + from: isMuc ? from : fromBare, + to: bareJid(to), + threadId: threadId || undefined, + roomId, + kind, + contentType, + body, + replyTo, + extensions: Object.keys(extensions).length ? extensions : undefined, + }; +} + +export function buildInboundEnvelope( + msg: AgentMessage, + gatewayId: string, + deliveryId: string, + xmppMeta: XmppSourceMetadata, + redelivered?: boolean, +): InboundMessage { + return { + type: "inbound.message", + message: msg, + delivery: { + receivedAt: new Date().toISOString(), + gatewayId, + deliveryId, + redelivered, + }, + xmpp: xmppMeta, + }; +} + +export function isAgentJid(jid: string, agentDomain: string): boolean { + const bare = bareJid(jid); + return bare.endsWith(`@${agentDomain}`); +} + +export function resolveTargetAgentJid( + to: string, + agentDomain: string, + defaultAgent: string, +): string { + const bare = bareJid(to); + if (isAgentJid(bare, agentDomain)) return bare; + // Traffic to the bare component address is attributed to the default agent for this gateway. + return defaultAgent; +} + +export function buildOutboundStanza( + req: OutboundDeliverRequest, + fromJid: string, +): Element { + if (isAskQuestionContent(req.content)) { + return buildAskQuestionFormStanza(req, fromJid, req.content); + } + + const id = req.id ?? ulid(); + const text = + typeof req.content === "string" + ? req.content + : (req.content as { text?: string })?.text || + (typeof req.content === "object" && req.content !== null + ? JSON.stringify(req.content) + : String(req.content)); + + const contentType = "text/plain"; + const payload = { + kind: "text", + contentType, + body: req.content, + }; + + const children: Element[] = [xml("body", {}, text)]; + const isMuc = isMucJid(req.to); + + if (req.threadId) { + children.push(xml("thread", {}, req.threadId)); + } + + if (req.inReplyTo) { + // XEP-0461: bare JID is only a MAY for 1:1; groupchat wants the full JID. + children.push( + xml("reply", { + xmlns: REPLY_NS, + id: req.inReplyTo, + to: isMuc ? req.to : bareJid(req.to), + }), + ); + } + + children.push( + xml("origin-id", { xmlns: ORIGIN_ID_NS, id: id }), + xml("content", { xmlns: CONTENT_TYPE_NS, type: contentType }), + xml( + "payload", + { xmlns: JSON_NS, datatype: contentType }, + xml("json", { xmlns: "urn:xmpp:json:0" }, JSON.stringify(payload)), + ), + ); + + // XEP-0184 §5.1/§5.5: request a delivery receipt on 1:1 messages only (never MUC), + // so the gateway can confirm the peer received it and resend otherwise. + if (!isMuc) { + children.push(xml("request", { xmlns: RECEIPTS_NS })); + } + + // RFC 6121 section 8.5.2.1: preserve a full JID when replying to the + // resource that originated a 1:1 chat. Proactive sends can still use bare JIDs. + const to = req.threadId && isMuc ? req.to : isMuc ? bareJid(req.to) : req.to; + const type = isMuc ? "groupchat" : "chat"; + + return xml( + "message", + { + type, + id, + to, + from: fromJid, + ...(req.lang ? { "xml:lang": req.lang } : {}), + }, + ...children, + ); +} + +export function applyStoreHints( + stanza: Element, + policy?: MessagePolicy, +): Element { + if (policy?.store === false) { + return xml( + "message", + stanza.attrs, + ...stanza.children, + xml("no-store", { xmlns: STORE_NS }), + ); + } + if (policy?.store === true) { + return xml( + "message", + stanza.attrs, + ...stanza.children, + xml("store", { xmlns: STORE_NS }), + ); + } + return stanza; +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/muc.ts b/packages/agent-xmpp/gateway/src/xep-plugins/muc.ts new file mode 100644 index 000000000..2f2597273 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/muc.ts @@ -0,0 +1,95 @@ +/** + * XEP-0045 Multi-User Chat presence and groupchat messages. + * Mentions use XEP-0513 wire format. Outbound uses the `jid` address form (the + * spec's non-anonymous fallback) — the gateway does not yet track XEP-0421 + * occupant-ids, which XEP-0513 mandates for rooms that support them. No begin/end + * offsets, since the gateway doesn't track where in the body a mention occurs. + * + * @see https://xmpp.org/extensions/xep-0045.html + * @see https://xmpp.org/extensions/xep-0513.html + */ + +import { xml, type Element } from "@xmpp/xml"; + +import { isMucJid } from "./jid.js"; +import { buildOutboundStanza } from "./message.js"; + +export { isMucJid }; + +const MUC_NS = "http://jabber.org/protocol/muc"; + +export interface XmppJoinRoomInput { + roomJid: string; + nickname?: string; + password?: string; +} +export interface XmppLeaveRoomInput { + roomJid: string; + nickname?: string; +} +export interface XmppSendRoomMessageInput { + roomJid: string; + body: string; + threadId?: string; + mentions?: string[]; +} + +export function buildJoinPresence( + input: XmppJoinRoomInput, + agentJid: string, +): Element { + const nick = input.nickname || agentJid.split("@")[0]; + const roomWithNick = `${input.roomJid}/${nick}`; + // XEP-0045 §7.2.2: request zero history so joining doesn't flood the agent + // with the room's backlog as fresh inbound messages. + const mucChildren: Element[] = [xml("history", { maxstanzas: "0" })]; + if (input.password) { + mucChildren.unshift(xml("password", {}, input.password)); + } + return xml( + "presence", + { to: roomWithNick, from: agentJid }, + xml("x", { xmlns: MUC_NS }, ...mucChildren), + ); +} + +export function buildLeavePresence( + input: XmppLeaveRoomInput, + agentJid: string, + nickname?: string, +): Element { + const nick = nickname || input.nickname || agentJid.split("@")[0]; + return xml("presence", { + to: `${input.roomJid}/${nick}`, + from: agentJid, + type: "unavailable", + }); +} + +export function buildRoomMessage( + input: XmppSendRoomMessageInput, + fromJid: string, +): Element { + const stanza = buildOutboundStanza( + { + from: fromJid, + to: input.roomJid, + threadId: input.threadId, + content: input.body, + }, + fromJid, + ); + stanza.attrs.type = "groupchat"; + + for (const m of input.mentions ?? []) { + stanza.append(xml("mention", { xmlns: "urn:xmpp:mentions:0", jid: m })); + } + + return stanza; +} + +export function mucRoomFromStanza(from: string): string | null { + if (!from.includes("/")) return null; + const [room] = from.split("/"); + return isMucJid(room) ? room : null; +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/ping.ts b/packages/agent-xmpp/gateway/src/xep-plugins/ping.ts new file mode 100644 index 000000000..a267cd168 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/ping.ts @@ -0,0 +1,24 @@ +/** + * XEP-0199 XMPP Ping. + * @see https://xmpp.org/extensions/xep-0199.html + */ +import { xml, type Element } from "@xmpp/xml"; + +export const PING_NS = "urn:xmpp:ping"; + +export function isPingRequest(stanza: Element): boolean { + return ( + stanza.name === "iq" && + stanza.attrs.type === "get" && + stanza.getChild("ping", PING_NS) != null + ); +} + +export function buildPingResponse(stanza: Element): Element { + return xml("iq", { + type: "result", + id: stanza.attrs.id, + from: stanza.attrs.to, + to: stanza.attrs.from, + }); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/presence.ts b/packages/agent-xmpp/gateway/src/xep-plugins/presence.ts new file mode 100644 index 000000000..642ed6b6a --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/presence.ts @@ -0,0 +1,113 @@ +/** + * Virtual-agent presence for an XEP-0114 component. + * + * Openfire cannot publish presence for virtual JIDs because they are not C2S + * accounts. The component therefore completes roster subscriptions and + * answers server probes itself. + * + * State mapping (RFC 6121): + * subscribe -> subscribed + available (§3.1.4 approving an inbound request) + * probe / '' -> available (§4.3 responding to a presence probe) + * unsubscribe -> unsubscribed (§3.3.2 canceling a subscription) + * + * @see https://www.rfc-editor.org/rfc/rfc6121#section-3 + */ +import { xml, type Element } from "@xmpp/xml"; + +import { bareJid } from "./jid.js"; + +export interface VirtualAgentIdentity { + jid: string; + name: string; +} + +export interface PresenceSubscriptionChange { + subscriberJid: string; + subscribed: boolean; +} + +export interface VirtualAgentPresenceResult { + responses: Element[]; + subscriptionChange?: PresenceSubscriptionChange; +} + +export const VIRTUAL_AGENT_RESOURCE = "gateway"; + +export function virtualAgentPresenceJid(agent: VirtualAgentIdentity): string { + return `${bareJid(agent.jid)}/${VIRTUAL_AGENT_RESOURCE}`; +} + +export function buildAvailablePresence( + agent: VirtualAgentIdentity, + to: string, +): Element { + return xml( + "presence", + { from: virtualAgentPresenceJid(agent), to }, + xml("show", {}, "chat"), + xml("status", {}, `${agent.name} is available`), + ); +} + +export function buildUnavailablePresence( + agent: VirtualAgentIdentity, + to: string, +): Element { + return xml("presence", { + type: "unavailable", + from: virtualAgentPresenceJid(agent), + to, + }); +} + +export function buildSubscriptionAccepted( + agent: VirtualAgentIdentity, + to: string, +): Element { + return xml("presence", { type: "subscribed", from: bareJid(agent.jid), to }); +} + +export function buildSubscriptionRemoved( + agent: VirtualAgentIdentity, + to: string, +): Element { + return xml("presence", { + type: "unsubscribed", + from: bareJid(agent.jid), + to, + }); +} + +export function handleVirtualAgentPresence( + stanza: Element, + agent: VirtualAgentIdentity, +): VirtualAgentPresenceResult { + if (stanza.name !== "presence") return { responses: [] }; + const to = String(stanza.attrs.from ?? ""); + if (!to) return { responses: [] }; + const type = String(stanza.attrs.type ?? ""); + const subscriberJid = bareJid(to); + if (type === "subscribe") { + return { + responses: [ + buildSubscriptionAccepted(agent, to), + buildAvailablePresence(agent, to), + ], + subscriptionChange: { subscriberJid, subscribed: true }, + }; + } + if (type === "probe") { + return { + responses: [buildAvailablePresence(agent, to)], + subscriptionChange: { subscriberJid, subscribed: true }, + }; + } + if (type === "") return { responses: [buildAvailablePresence(agent, to)] }; + if (type === "unsubscribe") { + return { + responses: [buildSubscriptionRemoved(agent, to)], + subscriptionChange: { subscriberJid, subscribed: false }, + }; + } + return { responses: [] }; +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/receipts.ts b/packages/agent-xmpp/gateway/src/xep-plugins/receipts.ts new file mode 100644 index 000000000..b2f9074ca --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/receipts.ts @@ -0,0 +1,53 @@ +/** + * XEP-0184 Message Delivery Receipts. + * Bodyless XEP-0085 chat states are filtered by the same routing guard. + * + * @see https://xmpp.org/extensions/xep-0184.html + * @see https://xmpp.org/extensions/xep-0085.html + */ + +import { xml, type Element } from "@xmpp/xml"; + +import { isChatStateStanza } from "./chatstate.js"; + +export const RECEIPTS_NS = "urn:xmpp:receipts"; + +/** The id a peer's acknowledges, or null if the stanza isn't a receipt. */ +export function receivedReceiptId(stanza: Element): string | null { + if (stanza.name !== "message") return null; + return ( + (stanza.getChild("received", RECEIPTS_NS)?.attrs.id as + | string + | undefined) ?? null + ); +} + +/** True for XEP-0085 chat states and XEP-0184 receipt stanzas with no conversational body. */ +export function isAckOrReceiptStanza(stanza: Element): boolean { + if (isChatStateStanza(stanza)) return true; + if (stanza.name !== "message") return false; + const body = stanza.getChildText("body"); + if (body?.trim()) return false; + if (stanza.getChild("received", RECEIPTS_NS)) return true; + if (stanza.getChild("request", RECEIPTS_NS)) return true; + return false; +} + +/** XEP-0184: only ack when the sender opted in with . */ +export function requestsReceipt(stanza: Element): boolean { + return ( + stanza.name === "message" && stanza.getChild("request", RECEIPTS_NS) != null + ); +} + +export function buildReceivedReceipt( + to: string, + from: string, + messageId: string, +): Element { + return xml( + "message", + { to, from, id: `receipt-${messageId}` }, + xml("received", { xmlns: RECEIPTS_NS, id: messageId }), + ); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/routing.ts b/packages/agent-xmpp/gateway/src/xep-plugins/routing.ts new file mode 100644 index 000000000..a351873cd --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/routing.ts @@ -0,0 +1,33 @@ +/** + * Plain-text @nick matching is the compatibility fallback for clients that do + * not send XEP-0513 Explicit Mentions. + * @see https://xmpp.org/extensions/xep-0513.html + */ +export function shouldDeliverInbound( + stanzaType: string, + isGroup: boolean, + isMention: boolean, +): boolean { + if (!isGroup) return true; + return isMention; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function detectMention(body: string, agentNick?: string): boolean { + if (!agentNick) return false; + // Escape metachars: a JID localpart can contain '.', '(', etc. Unescaped, they + // either false-match or make new RegExp throw and drop the stanza. + return new RegExp(`@${escapeRegExp(agentNick)}\\b`, "i").test(body); +} + +export function isMentionForAgent( + stanzaType: string, + body: string, + agentNick: string, +): boolean { + if (stanzaType === "chat") return true; + return detectMention(body, agentNick); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/search.ts b/packages/agent-xmpp/gateway/src/xep-plugins/search.ts new file mode 100644 index 000000000..ca302d0ea --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/search.ts @@ -0,0 +1,174 @@ +/** + * Agent-directory search via XEP-0055, with legacy fields for widely deployed + * clients and the recommended XEP-0004 form extension. + * + * @see https://xmpp.org/extensions/xep-0055.html + */ +import type { RegisteredAgent } from "@agent-xmpp/protocol"; +import { xml, type Element } from "@xmpp/xml"; + +import { DATA_FORMS_NS, SEARCH_NS } from "../agent-api-disco.js"; +import { buildRsm, pageRsm, parseRsm } from "../rsm-codec.js"; + +const INSTRUCTIONS = + "Enter a nickname to search for matching agents. Leave it empty to list up to 100 agents."; +const MAX_SEARCH_RESULTS = 100; + +function resultIq( + request: Element, + componentJid: string, + query: Element, +): Element { + return xml( + "iq", + { + type: "result", + id: request.attrs.id, + from: componentJid, + to: request.attrs.from, + ...(request.attrs["xml:lang"] + ? { "xml:lang": request.attrs["xml:lang"] } + : {}), + }, + query, + ); +} + +function dataFormFieldValue(form: Element, name: string): string { + return ( + form + .getChildren("field") + .find((field) => field.attrs.var === name) + ?.getChildText("value") + ?.trim() ?? "" + ); +} + +function nickname(agent: RegisteredAgent): string { + return agent.manifest.agent.title ?? agent.manifest.agent.name; +} + +export function buildSearchFields( + request: Element, + componentJid: string, +): Element { + return resultIq( + request, + componentJid, + xml( + "query", + { xmlns: SEARCH_NS }, + xml("instructions", {}, INSTRUCTIONS), + xml("nick"), + xml( + "x", + { xmlns: DATA_FORMS_NS, type: "form" }, + xml("title", {}, "Agent Directory Search"), + xml("instructions", {}, INSTRUCTIONS), + xml( + "field", + { type: "hidden", var: "FORM_TYPE" }, + xml("value", {}, SEARCH_NS), + ), + xml("field", { type: "text-single", label: "Nickname", var: "nick" }), + ), + ), + ); +} + +export function buildSearchResults( + request: Element, + componentJid: string, + agents: RegisteredAgent[], +): Element { + const query = request.getChild("query", SEARCH_NS); + const dataForm = query?.getChild("x", DATA_FORMS_NS); + if (dataForm && dataForm.attrs.type !== "submit") { + return resultIq(request, componentJid, xml("query", { xmlns: SEARCH_NS })); + } + const submittedForm = dataForm; + const needle = ( + submittedForm + ? dataFormFieldValue(submittedForm, "nick") + : (query?.getChildText("nick") ?? "") + ) + .trim() + .toLowerCase(); + const matches = agents.filter((agent) => { + if (!needle) return true; + const identity = agent.manifest.agent; + const localpart = identity.jid.split("@", 1)[0] ?? ""; + return [localpart, identity.name, identity.title ?? ""].some((value) => + value.toLowerCase().includes(needle), + ); + }); + const page = pageRsm( + matches, + (agent) => agent.manifest.agent.jid, + parseRsm(query!, MAX_SEARCH_RESULTS), + ); + + if (submittedForm) { + return resultIq( + request, + componentJid, + xml( + "query", + { xmlns: SEARCH_NS }, + xml( + "x", + { xmlns: DATA_FORMS_NS, type: "result" }, + xml( + "field", + { type: "hidden", var: "FORM_TYPE" }, + xml("value", {}, SEARCH_NS), + ), + xml( + "reported", + {}, + xml("field", { + var: "jid", + label: "Jabber ID", + type: "jid-single", + }), + xml("field", { + var: "nick", + label: "Nickname", + type: "text-single", + }), + ), + ...page.items.map((agent) => + xml( + "item", + {}, + xml( + "field", + { var: "jid" }, + xml("value", {}, agent.manifest.agent.jid), + ), + xml("field", { var: "nick" }, xml("value", {}, nickname(agent))), + ), + ), + ), + buildRsm(page), + ), + ); + } + + return resultIq( + request, + componentJid, + xml( + "query", + { xmlns: SEARCH_NS }, + ...page.items.map((agent) => + xml( + "item", + { jid: agent.manifest.agent.jid }, + xml("nick", {}, nickname(agent)), + ), + ), + buildRsm(page), + ), + ); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/vcard.ts b/packages/agent-xmpp/gateway/src/xep-plugins/vcard.ts new file mode 100644 index 000000000..3607f5fd1 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/vcard.ts @@ -0,0 +1,32 @@ +/** vCard-temp identity for virtual agents. @see https://xmpp.org/extensions/xep-0054.html */ +import type { RegisteredAgent } from "@agent-xmpp/protocol"; +import { xml, type Element } from "@xmpp/xml"; + +export const VCARD_TEMP_NS = "vcard-temp"; + +export function buildAgentVcard( + request: Element, + agent: RegisteredAgent, +): Element { + const identity = agent.manifest.agent; + const children = [ + xml("FN", {}, identity.title ?? identity.name), + xml("NICKNAME", {}, identity.name), + xml("JABBERID", {}, identity.jid), + ...(identity.description ? [xml("DESC", {}, identity.description)] : []), + ...(identity.homepage ? [xml("URL", {}, identity.homepage)] : []), + ...(identity.avatarUrl + ? [xml("PHOTO", {}, xml("EXTVAL", {}, identity.avatarUrl))] + : []), + ]; + return xml( + "iq", + { + type: "result", + id: request.attrs.id, + from: identity.jid, + to: request.attrs.from, + }, + xml("vCard", { xmlns: VCARD_TEMP_NS }, ...children), + ); +} diff --git a/packages/agent-xmpp/gateway/src/xmpp-component.ts b/packages/agent-xmpp/gateway/src/xmpp-component.ts new file mode 100644 index 000000000..c0d19b07c --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xmpp-component.ts @@ -0,0 +1,551 @@ +/** + * External-component session using XEP-0114 Jabber Component Protocol. + * @see https://xmpp.org/extensions/xep-0114.html + */ +import { component } from "@xmpp/component"; +import { xml, type Element } from "@xmpp/xml"; +import { ulid } from "ulid"; +import { bareJid } from "@agent-xmpp/protocol"; + +import type { GatewayConfig } from "./config.js"; + +export interface XmppComponentSession { + send: (stanza: Element) => Promise; + requestIq: (stanza: Element, options?: IqRequestOptions) => Promise; + start: () => Promise; + stop: () => Promise; + forceReconnect: (reason: string) => Promise; + getState: () => XmppConnectionState; + getLastActivityAt: () => number; + onStateChange: (handler: (state: XmppConnectionState) => void) => void; + onStanza: (handler: (stanza: Element) => void) => void; +} + +export type XmppConnectionState = + | "offline" + | "connecting" + | "online" + | "stopping"; + +export interface IqRequestOptions { + timeoutMs?: number; + signal?: AbortSignal; +} + +export class IqResponseError extends Error { + constructor(public readonly response: Element) { + const id = String(response.attrs.id ?? "unknown"); + const stanzaError = response.getChild("error"); + const condition = stanzaError?.children.find( + (child): child is Element => + typeof child !== "string" && child.name !== "text", + ); + super(`IQ request ${id} failed${condition ? `: ${condition.name}` : ""}`); + this.name = "IqResponseError"; + } +} + +export type IqGetHandler = ( + stanza: Element, +) => Element | null | Promise; + +const STANZA_ERROR_NS = "urn:ietf:params:xml:ns:xmpp-stanzas"; +const DEFAULT_IQ_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_PENDING_IQ_REQUESTS = 256; + +interface PendingIqRequest { + resolve: (stanza: Element) => void; + reject: (error: Error) => void; + timer: ReturnType; + signal?: AbortSignal; + onAbort?: () => void; + expectedFrom: string; + expectedTo: string; +} + +function abortError(id: string): Error { + const error = new Error(`IQ request ${id} aborted`); + error.name = "AbortError"; + return error; +} + +/** + * RFC 6120 §8.3 stanza error for an IQ get/set the gateway does not handle. + * `service-unavailable` (type cancel) is the standard "no such handler" reply; + * the original request payload is echoed back per the SHOULD in §8.3.1. + */ +export function buildIqError(request: Element): Element { + return xml( + "iq", + { + type: "error", + id: request.attrs.id, + from: request.attrs.to, + to: request.attrs.from, + }, + ...request.children.filter((c): c is Element => typeof c !== "string"), + xml( + "error", + { type: "cancel" }, + xml("service-unavailable", { xmlns: STANZA_ERROR_NS }), + ), + ); +} + +function iqMiddlewareReply(response: Element): Element | true { + if (response.attrs.type === "error") { + return ( + response.getChild("error") ?? + xml( + "error", + { type: "cancel" }, + xml("service-unavailable", { xmlns: STANZA_ERROR_NS }), + ) + ); + } + return response.getChildElements()[0] ?? true; +} + +export type IqDisposition = + | { kind: "respond"; stanza: Element } + | { kind: "error" } + | { kind: "dispatch" }; + +/** + * Decide how an inbound stanza is handled by the component: + * - IQ get/set the gateway answers -> `respond` with the built reply + * - IQ get/set nothing handled -> `error` (RFC 6120 §8.2.3 requires a reply) + * - everything else, incl. IQ result/error responses to our own outbound requests + * and all message/presence stanzas -> `dispatch` to the registered stanza handlers + */ +export async function dispositionForStanza( + stanza: Element, + onIqGet?: IqGetHandler, +): Promise { + if (stanza.name === "iq") { + const type = String(stanza.attrs.type ?? ""); + if (type === "get" || type === "set") { + const response = (await onIqGet?.(stanza)) ?? null; + return response + ? { kind: "respond", stanza: response } + : { kind: "error" }; + } + } + return { kind: "dispatch" }; +} + +export function reconnectDelayMs( + attempt: number, + initialMs: number, + maxMs: number, + random = Math.random, +): number { + const exponential = Math.min( + maxMs, + initialMs * 2 ** Math.min(Math.max(attempt - 1, 0), 30), + ); + return Math.max(1, Math.round(exponential * (0.8 + random() * 0.4))); +} + +export function createComponentSession( + config: GatewayConfig, + onIqGet?: IqGetHandler, +): XmppComponentSession { + const maxPendingIqRequests = + config.maxPendingIqRequests ?? DEFAULT_MAX_PENDING_IQ_REQUESTS; + if ( + !Number.isSafeInteger(maxPendingIqRequests) || + maxPendingIqRequests <= 0 + ) { + throw new Error("maxPendingIqRequests must be a positive integer"); + } + const stanzaHandlers: Array<(stanza: Element) => void> = []; + const stateHandlers: Array<(state: XmppConnectionState) => void> = []; + const pendingIqRequests = new Map(); + let activeInboundIq = 0; + let activeClient: ReturnType | null = null; + let state: XmppConnectionState = "offline"; + let stopped = true; + let reconnectAttempt = 0; + let reconnectTimer: ReturnType | null = null; + let lastActivityAt = Date.now(); + let onlineAttempt: { + client: ReturnType; + resolve: () => void; + reject: (error: Error) => void; + } | null = null; + + const transition = (next: XmppConnectionState): void => { + if (state === next) return; + state = next; + for (const handler of stateHandlers) handler(next); + }; + + const settleIqRequest = ( + id: string, + responseOrError: Element | Error, + ): boolean => { + const pending = pendingIqRequests.get(id); + if (!pending) return false; + if (!(responseOrError instanceof Error)) { + const responseFrom = bareJid(String(responseOrError.attrs.from ?? "")); + const responseTo = bareJid(String(responseOrError.attrs.to ?? "")); + if ( + (pending.expectedFrom && responseFrom !== pending.expectedFrom) || + (pending.expectedTo && responseTo !== pending.expectedTo) + ) { + return false; + } + } + + pendingIqRequests.delete(id); + clearTimeout(pending.timer); + if (pending.signal && pending.onAbort) + pending.signal.removeEventListener("abort", pending.onAbort); + + if (responseOrError instanceof Error) pending.reject(responseOrError); + else if (responseOrError.attrs.type === "error") + pending.reject(new IqResponseError(responseOrError)); + else pending.resolve(responseOrError); + return true; + }; + + const rejectPendingIqRequests = (reason: string): void => { + for (const id of [...pendingIqRequests.keys()]) { + settleIqRequest(id, new Error(`IQ request ${id} failed: ${reason}`)); + } + }; + + const rejectOnlineAttempt = ( + client: ReturnType, + reason: Error, + ): void => { + if (onlineAttempt?.client !== client) return; + const attempt = onlineAttempt; + onlineAttempt = null; + attempt.reject(reason); + }; + + const clearReconnectTimer = (): void => { + if (!reconnectTimer) return; + clearTimeout(reconnectTimer); + reconnectTimer = null; + }; + + const scheduleReconnect = (): void => { + if (stopped || reconnectTimer) return; + reconnectAttempt += 1; + const delay = reconnectDelayMs( + reconnectAttempt, + config.reconnectInitialMs, + config.reconnectMaxMs, + ); + console.error( + `[xmpp-gateway] reconnect attempt ${reconnectAttempt} scheduled in ${delay}ms`, + ); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + void connectClient(); + }, delay); + reconnectTimer.unref?.(); + }; + + const handleConnectionLoss = ( + client: ReturnType, + reason: string, + ): void => { + if (activeClient !== client || stopped || state === "stopping") return; + activeClient = null; + transition("offline"); + rejectOnlineAttempt(client, new Error(reason)); + rejectPendingIqRequests(reason); + scheduleReconnect(); + }; + + const createClient = (): ReturnType => { + const client = component({ + service: config.componentService, + domain: config.componentJid, + password: config.componentSecret, + }); + // The package helper makes only one fixed-delay attempt. The gateway owns a + // capped, jittered supervisor and creates a clean client for every attempt. + client.reconnect.stop(); + + // @xmpp/component installs its IQ callee as middleware. Handling IQs only + // from the raw `stanza` event races that callee: it immediately emits + // service-unavailable while our listener later emits the valid result. + // Participate in the middleware chain so every request gets exactly one + // response. + const middlewareClient = client as typeof client & { + middleware: { + use( + handler: ( + context: { stanza: Element }, + next: () => Promise, + ) => Promise, + ): unknown; + }; + }; + middlewareClient.middleware.use(async (context, next) => { + const stanza = context.stanza as Element; + const type = String(stanza.attrs.type ?? ""); + if (stanza.name !== "iq" || (type !== "get" && type !== "set")) + return next(); + if (activeInboundIq >= maxPendingIqRequests) { + return xml( + "error", + { type: "wait" }, + xml("resource-constraint", { xmlns: STANZA_ERROR_NS }), + ); + } + activeInboundIq++; + try { + const disposition = await dispositionForStanza(stanza, onIqGet); + const response = + disposition.kind === "respond" + ? disposition.stanza + : buildIqError(stanza); + return iqMiddlewareReply(response); + } catch (err) { + console.error("[xmpp-gateway] inbound IQ handling failed:", err); + return iqMiddlewareReply(buildIqError(stanza)); + } finally { + activeInboundIq--; + } + }); + + client.on("stanza", (stanza: Element) => { + if (activeClient !== client) return; + lastActivityAt = Date.now(); + const type = String(stanza.attrs.type ?? ""); + const id = String(stanza.attrs.id ?? ""); + // Correlate outbound requests before any inbound protocol routing. + if ( + stanza.name === "iq" && + id && + (type === "result" || type === "error") && + settleIqRequest(id, stanza) + ) { + return; + } + + if (stanza.name === "iq" && (type === "get" || type === "set")) { + // The @xmpp/component IQ middleware above owns the response. + return; + } + for (const handler of stanzaHandlers) handler(stanza); + }); + + client.on("error", (err: Error) => { + if (activeClient === client) { + console.error("[xmpp-gateway] component error:", err.message); + rejectOnlineAttempt(client, err); + } + }); + client.on("online", () => { + if (activeClient !== client || stopped) return; + reconnectAttempt = 0; + clearReconnectTimer(); + lastActivityAt = Date.now(); + transition("online"); + if (onlineAttempt?.client === client) { + const attempt = onlineAttempt; + onlineAttempt = null; + attempt.resolve(); + } + }); + client.on("disconnect", () => + handleConnectionLoss(client, "component disconnected"), + ); + client.on("offline", () => + handleConnectionLoss(client, "component went offline"), + ); + return client; + }; + + async function connectClient(): Promise { + if (stopped || state === "connecting" || state === "online") return; + transition("connecting"); + const client = createClient(); + activeClient = client; + try { + // Avoid Component.start(): @xmpp/connection creates an internal + // `online` promise before `open()`, and both promises reject on a + // connection error. Only one is awaited upstream, producing an + // unhandled rejection during ordinary reconnect failures. + await client.connect(config.componentService); + const onlinePromise = new Promise((resolve, reject) => { + onlineAttempt = { client, resolve, reject }; + }); + // A disconnect can reject this while client.open() is still pending. + // Handle that timing window immediately; awaiting the original promise + // below still propagates the rejection. + void onlinePromise.catch(() => undefined); + try { + await client.open({ domain: config.componentJid }); + await onlinePromise; + } catch (error: unknown) { + rejectOnlineAttempt( + client, + error instanceof Error ? error : new Error(String(error)), + ); + await onlinePromise.catch(() => undefined); + throw error; + } + if (activeClient === client && !stopped) { + reconnectAttempt = 0; + lastActivityAt = Date.now(); + transition("online"); + console.error( + `[xmpp-gateway] component online: ${config.componentJid}`, + ); + } + } catch (error: unknown) { + if (activeClient === client) activeClient = null; + client.reconnect.stop(); + transition("offline"); + const message = error instanceof Error ? error.message : String(error); + console.error(`[xmpp-gateway] component connection failed: ${message}`); + scheduleReconnect(); + await client.stop().catch(() => undefined); + } + } + + const send = async (stanza: Element): Promise => { + const client = activeClient; + if (state !== "online" || !client) + throw new Error("XMPP component is offline"); + await client.send(stanza); + lastActivityAt = Date.now(); + }; + + const requestIq = ( + stanza: Element, + options: IqRequestOptions = {}, + ): Promise => { + if (state !== "online") + return Promise.reject( + new Error("Cannot send IQ request while component is offline"), + ); + + const type = String(stanza.attrs.type ?? ""); + if (stanza.name !== "iq" || (type !== "get" && type !== "set")) { + return Promise.reject( + new Error( + 'Outbound IQ request must be an or stanza', + ), + ); + } + + const timeoutMs = options.timeoutMs ?? DEFAULT_IQ_TIMEOUT_MS; + if ( + !Number.isFinite(timeoutMs) || + !Number.isInteger(timeoutMs) || + timeoutMs <= 0 + ) { + return Promise.reject( + new Error("IQ request timeoutMs must be a positive integer"), + ); + } + if (pendingIqRequests.size >= maxPendingIqRequests) { + return Promise.reject( + new Error( + `Too many pending IQ requests (limit ${maxPendingIqRequests})`, + ), + ); + } + + const id = String(stanza.attrs.id ?? "") || ulid(); + if (pendingIqRequests.has(id)) { + return Promise.reject( + new Error(`IQ request id is already pending: ${id}`), + ); + } + stanza.attrs.id = id; + + if (options.signal?.aborted) return Promise.reject(abortError(id)); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + settleIqRequest( + id, + new Error(`IQ request ${id} timed out after ${timeoutMs}ms`), + ); + }, timeoutMs); + timer.unref?.(); + + const pending: PendingIqRequest = { + resolve, + reject, + timer, + signal: options.signal, + expectedFrom: bareJid(String(stanza.attrs.to ?? "")), + expectedTo: bareJid(String(stanza.attrs.from ?? "")), + }; + if (options.signal) { + pending.onAbort = () => settleIqRequest(id, abortError(id)); + options.signal.addEventListener("abort", pending.onAbort, { + once: true, + }); + } + pendingIqRequests.set(id, pending); + + try { + send(stanza).catch((error: unknown) => { + const sendError = + error instanceof Error ? error : new Error(String(error)); + settleIqRequest(id, sendError); + }); + } catch (error: unknown) { + const sendError = + error instanceof Error ? error : new Error(String(error)); + settleIqRequest(id, sendError); + } + }); + }; + + return { + send, + requestIq, + start: async () => { + if (!stopped) return; + stopped = false; + reconnectAttempt = 0; + clearReconnectTimer(); + await connectClient(); + }, + stop: async () => { + if (stopped && state === "offline") return; + stopped = true; + clearReconnectTimer(); + transition("stopping"); + rejectPendingIqRequests("component stopped"); + const client = activeClient; + activeClient = null; + if (client) rejectOnlineAttempt(client, new Error("component stopped")); + client?.reconnect.stop(); + if (client) await client.stop().catch(() => undefined); + transition("offline"); + }, + forceReconnect: async (reason) => { + if (stopped || state === "stopping") return; + const client = activeClient; + activeClient = null; + transition("offline"); + if (client) rejectOnlineAttempt(client, new Error(reason)); + rejectPendingIqRequests(reason); + client?.reconnect.stop(); + if (client) await client.stop().catch(() => undefined); + scheduleReconnect(); + }, + getState: () => state, + getLastActivityAt: () => lastActivityAt, + onStateChange: (handler) => stateHandlers.push(handler), + onStanza: (handler) => { + stanzaHandlers.push(handler); + }, + }; +} + +export { xml }; diff --git a/packages/agent-xmpp/gateway/src/xmpp-keepalive.ts b/packages/agent-xmpp/gateway/src/xmpp-keepalive.ts new file mode 100644 index 000000000..c25de3e99 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xmpp-keepalive.ts @@ -0,0 +1,66 @@ +import type { XmppConnectionState } from "./xmpp-component.js"; + +export interface XmppKeepaliveOptions { + intervalMs: number; + failureThreshold: number; +} + +export interface XmppKeepaliveCallbacks { + getState: () => XmppConnectionState; + getLastActivityAt: () => number; + ping: () => Promise; + forceReconnect: (reason: string) => Promise; + now?: () => number; +} + +/** Idle XEP-0199 probe loop. Connection recovery remains owned by the session supervisor. */ +export class XmppKeepalive { + private timer: ReturnType | null = null; + private inFlight = false; + private consecutiveFailures = 0; + + constructor( + private readonly options: XmppKeepaliveOptions, + private readonly callbacks: XmppKeepaliveCallbacks, + ) {} + + start(): void { + if (this.timer) return; + this.timer = setInterval(() => void this.check(), this.options.intervalMs); + this.timer.unref?.(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + this.inFlight = false; + this.consecutiveFailures = 0; + } + + private async check(): Promise { + if (this.inFlight || this.callbacks.getState() !== "online") return; + const now = this.callbacks.now?.() ?? Date.now(); + if (now - this.callbacks.getLastActivityAt() < this.options.intervalMs) + return; + + this.inFlight = true; + try { + await this.callbacks.ping(); + this.consecutiveFailures = 0; + } catch (error: unknown) { + this.consecutiveFailures += 1; + const message = error instanceof Error ? error.message : String(error); + console.error( + `[xmpp-gateway] keepalive failed (${this.consecutiveFailures}/${this.options.failureThreshold}): ${message}`, + ); + if (this.consecutiveFailures >= this.options.failureThreshold) { + this.consecutiveFailures = 0; + await this.callbacks.forceReconnect( + "XEP-0199 keepalive failure threshold reached", + ); + } + } finally { + this.inFlight = false; + } + } +} diff --git a/packages/agent-xmpp/gateway/src/xmpp-shims.d.ts b/packages/agent-xmpp/gateway/src/xmpp-shims.d.ts new file mode 100644 index 000000000..47ce2de66 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xmpp-shims.d.ts @@ -0,0 +1,60 @@ +declare module "@xmpp/xml" { + export class Element { + name: string; + attrs: Record; + children: Array; + + getChild(name: string, xmlns?: string): Element | undefined; + getChildElements(): Element[]; + getChildren(name: string, xmlns?: string): Element[]; + getChildText(name: string, xmlns?: string): string | null; + getNS(): string; + getText(): string; + append(child: Element | string): this; + toString(): string; + } + + export class Parser { + on( + event: "start" | "element" | "end", + handler: (element: Element) => void, + ): this; + on(event: "error", handler: (error: Error) => void): this; + write(data: string): void; + end(data?: string): void; + } + + export function xml( + name: string, + attrs?: Record, + ...children: Array + ): Element; + + export default xml; +} + +declare module "@xmpp/component" { + import type { Element } from "@xmpp/xml"; + + interface ComponentClient { + on(event: "stanza", handler: (stanza: Element) => void): this; + on(event: "error", handler: (error: Error) => void): this; + on(event: "offline", handler: () => void): this; + on(event: "online", handler: () => void): this; + on(event: "disconnect", handler: () => void): this; + reconnect: { + stop(): void; + }; + connect(service: string): Promise; + open(options: { domain: string }): Promise; + send(stanza: Element): Promise; + start(): Promise; + stop(): Promise; + } + + export function component(options: { + service: string; + domain: string; + password: string; + }): ComponentClient; +} diff --git a/packages/agent-xmpp/gateway/tsconfig.json b/packages/agent-xmpp/gateway/tsconfig.json new file mode 100644 index 000000000..5d0f16ada --- /dev/null +++ b/packages/agent-xmpp/gateway/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/agent-xmpp/protocol/package.json b/packages/agent-xmpp/protocol/package.json new file mode 100644 index 000000000..71099d4bb --- /dev/null +++ b/packages/agent-xmpp/protocol/package.json @@ -0,0 +1,29 @@ +{ + "name": "@agent-xmpp/protocol", + "version": "0.1.0", + "description": "Shared XMPP agent gateway protocol types", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./schema/event.schema.json": "./schema/event.schema.json", + "./schema/manifest.schema.json": "./schema/manifest.schema.json" + }, + "scripts": { + "build": "rm -rf dist && node ../../../node_modules/typescript/bin/tsc", + "test": "bun test src", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^26.5.0", + "typescript": "^7.0.2" + }, + "dependencies": { + "idn-hostname": "17.0.3", + "precis-wasm": "0.1.0" + } +} diff --git a/packages/agent-xmpp/protocol/schema/agent-api.xsd b/packages/agent-xmpp/protocol/schema/agent-api.xsd new file mode 100644 index 000000000..794ff611d --- /dev/null +++ b/packages/agent-xmpp/protocol/schema/agent-api.xsd @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/agent-xmpp/protocol/schema/agent-task.xsd b/packages/agent-xmpp/protocol/schema/agent-task.xsd new file mode 100644 index 000000000..ecb7107a7 --- /dev/null +++ b/packages/agent-xmpp/protocol/schema/agent-task.xsd @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/agent-xmpp/protocol/schema/event.schema.json b/packages/agent-xmpp/protocol/schema/event.schema.json new file mode 100644 index 000000000..f8ed8e042 --- /dev/null +++ b/packages/agent-xmpp/protocol/schema/event.schema.json @@ -0,0 +1,140 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:xmpp:agent-task:0:event-json", + "$defs": { + "status": { + "type": "object", + "required": ["state", "updatedAt"], + "properties": { + "state": { + "enum": ["running", "input_required", "cancelling"] + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "progress": { + "type": "object", + "minProperties": 1, + "properties": { + "percent": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "stage": { + "type": "string", + "maxLength": 256 + }, + "message": { + "type": "string", + "maxLength": 4096 + } + }, + "additionalProperties": false + }, + "input_required": { + "type": "object", + "required": ["requestId", "question", "inputSchema", "createdAt"], + "properties": { + "requestId": { + "type": "string", + "pattern": "^[A-Za-z0-9._~-]{22,128}$" + }, + "question": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "inputSchema": { + "type": "object" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "toolResult": { + "type": "object", + "required": ["content"], + "properties": { + "content": { + "type": "array", + "items": { + "type": "object" + } + }, + "structuredContent": {}, + "isError": { + "type": "boolean" + }, + "_meta": { + "type": "object" + } + }, + "additionalProperties": false + }, + "completed": { + "type": "object", + "required": ["result"], + "properties": { + "result": { + "$ref": "#/$defs/toolResult" + }, + "summary": { + "type": "string", + "maxLength": 4096 + } + }, + "additionalProperties": false + }, + "failed": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message", "retryable"], + "properties": { + "code": { + "type": "string", + "pattern": "^[A-Za-z0-9_.:-]{1,128}$" + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "retryable": { + "type": "boolean" + }, + "details": { + "type": "object" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "cancelled": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "maxLength": 4096 + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/agent-xmpp/protocol/schema/manifest.schema.json b/packages/agent-xmpp/protocol/schema/manifest.schema.json new file mode 100644 index 000000000..7690e1656 --- /dev/null +++ b/packages/agent-xmpp/protocol/schema/manifest.schema.json @@ -0,0 +1,163 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:xmpp:agent-api:0:manifest-json", + "type": "object", + "required": ["manifestSpecVersion", "agent", "tools"], + "properties": { + "manifestSpecVersion": { + "const": "0" + }, + "agent": { + "type": "object", + "required": ["jid", "name", "version"], + "properties": { + "jid": { + "type": "string", + "minLength": 3, + "maxLength": 3071 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "title": { + "type": "string", + "maxLength": 256 + }, + "description": { + "type": "string", + "maxLength": 4096 + }, + "version": { + "type": "string", + "pattern": "^[A-Za-z0-9._~-]{1,64}$" + }, + "vendor": { + "type": "string", + "maxLength": 256 + }, + "homepage": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "maxLength": 2048 + }, + "avatarUrl": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "maxLength": 2048 + } + }, + "additionalProperties": false + }, + "implementation": { + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "additionalProperties": false + }, + "mcpProtocolVersion": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + }, + "tools": { + "type": "array", + "maxItems": 4096, + "items": { + "type": "object", + "required": ["name", "inputSchema"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "maxLength": 256 + }, + "description": { + "type": "string", + "maxLength": 8192 + }, + "inputSchema": { + "type": "object" + }, + "outputSchema": { + "type": "object" + }, + "annotations": { + "type": "object" + }, + "execution": { + "type": "object" + }, + "_meta": { + "type": "object" + }, + "urn:xmpp:agent-api:0": { + "type": "object", + "properties": { + "supportsProgress": { + "type": "boolean" + }, + "supportsCancellation": { + "type": "boolean" + }, + "supportsInput": { + "type": "boolean" + }, + "defaultTimeoutSeconds": { + "type": "integer", + "minimum": 1 + }, + "maximumTimeoutSeconds": { + "type": "integer", + "minimum": 1 + }, + "requiredPermissions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "uniqueItems": true + }, + "approvalRequired": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "uniqueItems": true + } + }, + "additionalProperties": false + } + }, + "patternProperties": { + "^[a-z][a-z0-9+.-]*:": {} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/packages/agent-xmpp/protocol/schema/namespaces.json b/packages/agent-xmpp/protocol/schema/namespaces.json new file mode 100644 index 000000000..0d9dc197e --- /dev/null +++ b/packages/agent-xmpp/protocol/schema/namespaces.json @@ -0,0 +1,10 @@ +[ + "urn:xmpp:agent-directory:0", + "urn:xmpp:agent-api:0", + "urn:xmpp:agent-tools:0", + "urn:xmpp:agent-tool:0", + "urn:xmpp:agent-endpoint:0", + "urn:xmpp:agent-endpoint-info:0", + "urn:xmpp:agent-tool-info:0", + "urn:xmpp:agent-task:0" +] diff --git a/packages/agent-xmpp/protocol/src/agent-api.ts b/packages/agent-xmpp/protocol/src/agent-api.ts new file mode 100644 index 000000000..5ebff25d5 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/agent-api.ts @@ -0,0 +1,92 @@ +import type { AGENT_API_NS } from "./namespaces.js"; + +export type JsonSchema = Record; + +/** MCP Tool annotations are preserved exactly; absence is distinct from false. */ +export interface McpToolAnnotations { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; +} + +export interface McpTool { + name: string; + title?: string; + description?: string; + inputSchema: JsonSchema; + outputSchema?: JsonSchema; + annotations?: McpToolAnnotations; + execution?: Record; + _meta?: Record; + [extension: `${string}:${string}`]: unknown; +} + +export interface XmppToolExtension { + supportsProgress?: boolean; + supportsCancellation?: boolean; + supportsInput?: boolean; + defaultTimeoutSeconds?: number; + maximumTimeoutSeconds?: number; + requiredPermissions?: string[]; + approvalRequired?: boolean; + tags?: string[]; +} + +export interface AgentApiManifest { + manifestSpecVersion: "0"; + agent: { + jid: string; + name: string; + title?: string; + description?: string; + version: string; + vendor?: string; + homepage?: string; + /** Public avatar URI served via XEP-0054 PHOTO/EXTVAL. */ + avatarUrl?: string; + }; + implementation?: { name: string; version: string }; + mcpProtocolVersion?: string; + tools: McpTool[]; +} + +export interface RegisteredTool extends McpTool { + inputSchemaHash: string; + outputSchemaHash?: string; + xmpp?: XmppToolExtension; +} + +export interface RegisteredAgent { + manifest: AgentApiManifest; + manifestHash: string; + canonicalManifest: string; + tools: RegisteredTool[]; + tenantId: string; + active: boolean; + registeredAt: string; +} + +export interface VirtualMcpEndpoint { + endpointId: string; + manifestSpecVersion: AgentApiManifest["manifestSpecVersion"]; + implementation?: AgentApiManifest["implementation"]; + mcpProtocolVersion?: AgentApiManifest["mcpProtocolVersion"]; + server: { + name: string; + title?: string; + description?: string; + version: string; + }; + xmpp: { + jid: string; + toolsNode: string; + features: string[]; + }; + authorization: { visible: boolean; invocable: boolean }; + tools: RegisteredTool[]; +} + +export const XMPP_TOOL_EXTENSION_KEY: typeof AGENT_API_NS = + "urn:xmpp:agent-api:0"; diff --git a/packages/agent-xmpp/protocol/src/agent-message.ts b/packages/agent-xmpp/protocol/src/agent-message.ts new file mode 100644 index 000000000..c274e1ac3 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/agent-message.ts @@ -0,0 +1,174 @@ +/** Normative types from Agent XMPP Adapter API Surface v0.1 */ + +export type MessageKind = + | "text" + | "task" + | "result" + | "error" + | "file" + | "command" + | "event"; + +export type Sensitivity = "public" | "internal" | "confidential" | "secret"; + +export interface TraceContext { + tenantId?: string; + workflowId?: string; + runId?: string; + spanId?: string; + correlationId?: string; +} + +export interface MessagePolicy { + store?: boolean; + ttlSeconds?: number | null; + trainingAllowed?: boolean; + containsPii?: boolean; + sensitivity?: Sensitivity; +} + +export interface FileRef { + id?: string; + name?: string; + url: string; + mediaType?: string; + sizeBytes?: number; + sha256?: string; + description?: string; + expiresAt?: string; + encrypted?: boolean; + metadata?: Record; +} + +export interface AgentMessage { + id: string; + from: string; + to: string; + threadId?: string; + roomId?: string; + kind: MessageKind; + contentType: string; + body: unknown; + replyTo?: string; + attachments?: FileRef[]; + trace?: TraceContext; + policy?: MessagePolicy; + extensions?: Record; +} + +export interface XmppSourceMetadata { + stanzaId?: string; + stableId?: string; + stanzaType?: "chat" | "groupchat" | "normal" | "headline" | "error"; + fromResource?: string; + toResource?: string; + mucOccupantId?: string; + delayed?: { + stamp: string; + from?: string; + }; + rawNamespaces?: string[]; +} + +export interface DeliveryMeta { + receivedAt: string; + gatewayId: string; + deliveryId: string; + redelivered?: boolean; +} + +export interface InboundMessage { + type: "inbound.message"; + message: AgentMessage; + delivery: DeliveryMeta; + xmpp?: XmppSourceMetadata; +} + +export interface InboundEvent { + type: "inbound.event"; + event: Record; + delivery: DeliveryMeta; +} + +export interface InboundCommand { + type: "inbound.command"; + command: string; + args?: Record; + delivery: DeliveryMeta; +} + +export interface InboundLifecycleEvent { + type: "inbound.lifecycle"; + lifecycle: Record; + delivery: DeliveryMeta; +} + +export type InboundEnvelope = + | InboundMessage + | InboundEvent + | InboundCommand + | InboundLifecycleEvent; + +/** ask_user_question payload — shared between host delivery and XMPP form rendering. */ +export interface AskQuestionOption { + label: string; + selectedLabel?: string; + value?: string; +} + +export type AskQuestionOptionInput = string | AskQuestionOption; + +export interface AskQuestionPayload { + type: "ask_question"; + questionId: string; + title: string; + question: string; + options: AskQuestionOptionInput[]; +} + +/** Bridge webhook payload: routing + normalized message for NanoClaw. */ +export interface BridgeInboundPayload { + platformId: string; + /** Full sender JID used for replies and chat states; routing still uses platformId. */ + replyTo?: string; + threadId: string | null; + isMention?: boolean; + isGroup?: boolean; + agentJid: string; + envelope: InboundMessage; +} + +/** XEP-0004 form submit for ask_user_question — routed to host onAction, not the agent. */ +export interface BridgeFormResponsePayload { + type: "form_response"; + agentJid: string; + platformId: string; + threadId: string | null; + questionId: string; + selectedIndex: number; + userId: string; + timestamp: string; +} + +export type BridgeWebhookPayload = + | BridgeInboundPayload + | BridgeFormResponsePayload; + +export function isBridgeFormResponsePayload( + payload: BridgeWebhookPayload, +): payload is BridgeFormResponsePayload { + return "type" in payload && payload.type === "form_response"; +} + +/** Gateway outbound deliver request from NanoClaw bridge. */ +export interface OutboundDeliverRequest { + id?: string; + from: string; + to: string; + /** BCP 47 language tag used as the stanza's inherited xml:lang. */ + lang?: string; + threadId?: string | null; + content: unknown; + inReplyTo?: string; + files?: Array<{ filename: string; dataBase64: string; mediaType?: string }>; +} diff --git a/packages/agent-xmpp/protocol/src/agent-task.ts b/packages/agent-xmpp/protocol/src/agent-task.ts new file mode 100644 index 000000000..3c3bb50bc --- /dev/null +++ b/packages/agent-xmpp/protocol/src/agent-task.ts @@ -0,0 +1,82 @@ +export const taskStates = [ + "accepted", + "running", + "input_required", + "cancelling", + "cancelled", + "failed", + "completed", +] as const; +export type AgentTaskState = (typeof taskStates)[number]; + +export const terminalTaskStates = new Set([ + "cancelled", + "failed", + "completed", +]); + +export interface AgentTaskError { + code: string; + message: string; + retryable: boolean; + details?: Record; +} + +export interface McpToolResult { + content: Array>; + structuredContent?: unknown; + isError?: boolean; + _meta?: Record; +} + +export interface PendingTaskInput { + requestId: string; + question: string; + inputSchema: Record; + createdAt: string; + expiresAt?: string; +} + +export interface AgentTaskRecord { + taskId: string; + requestId: string; + callerJid: string; + notificationJid: string; + targetJid: string; + tenantId: string; + tool: string; + apiVersion: string; + manifestHash: string; + arguments: unknown; + state: AgentTaskState; + revision: number; + fingerprint: string; + callerSessionId?: string; + createdAt: string; + updatedAt: string; + deadline?: string; + retainUntil: string; + result?: McpToolResult; + error?: AgentTaskError; + summary?: string; + pendingInput?: PendingTaskInput; +} + +export const taskEventTypes = [ + "status", + "progress", + "input_required", + "completed", + "failed", + "cancelled", +] as const; +export type AgentTaskEventType = (typeof taskEventTypes)[number]; + +export interface AgentTaskEvent { + taskId: string; + eventId: string; + revision: number; + type: AgentTaskEventType; + payload: Record; + createdAt: string; +} diff --git a/packages/agent-xmpp/protocol/src/bridge.ts b/packages/agent-xmpp/protocol/src/bridge.ts new file mode 100644 index 000000000..9e3b8ae37 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/bridge.ts @@ -0,0 +1,67 @@ +import type { + AgentMessage, + BridgeInboundPayload, + InboundMessage, +} from "./agent-message.js"; + +/** + * True when the gateway normalized a XEP-0432-inspired JSON payload (agent-to-agent), + * as opposed to a plain human `` stanza (kind=text, contentType=text/plain, string body). + */ +export function isXmppAgentEnvelope(msg: AgentMessage): boolean { + if (msg.kind !== "text") return true; + if (msg.contentType !== "text/plain") return true; + return typeof msg.body !== "string"; +} + +/** Extract the normative AgentMessage from a NanoClaw XMPP inbound content JSON blob. */ +export function agentMessageFromNanoclawContent( + raw: string, +): AgentMessage | null { + try { + const parsed = JSON.parse(raw) as { envelope?: InboundMessage }; + if (parsed.envelope?.type !== "inbound.message") return null; + return parsed.envelope.message; + // eslint-disable-next-line no-catch-all/no-catch-all -- malformed inbound content returns null + } catch { + return null; + } +} + +/** Human-readable text from a normative AgentMessage. */ +export function agentMessageText(msg: AgentMessage): string { + if (typeof msg.body === "string") return msg.body; + if (msg.body && typeof msg.body === "object" && "text" in msg.body) { + return String((msg.body as { text?: unknown }).text ?? ""); + } + return JSON.stringify(msg.body); +} + +/** NanoClaw channel adapter inbound shape — preserves normative envelope. */ +export interface NanoclawXmppInbound { + id: string; + kind: "chat"; + content: { text: string; envelope: InboundMessage }; + timestamp: string; + isMention?: boolean; + isGroup?: boolean; +} + +export function nanoclawInboundFromBridge( + payload: BridgeInboundPayload, +): NanoclawXmppInbound { + const { envelope } = payload; + const text = agentMessageText(envelope.message); + return { + id: envelope.message.id, + // Generic AgentMessage(kind="task") is structured conversation content, + // not a durable gateway task. Only the agent-task stanza codec creates a + // task record and exposes lifecycle tools, so an arbitrary message id can + // never be mistaken for a registered task id. + kind: "chat", + content: { text, envelope }, + timestamp: envelope.delivery.receivedAt, + isMention: payload.isMention, + isGroup: payload.isGroup, + }; +} diff --git a/packages/agent-xmpp/protocol/src/identifiers.ts b/packages/agent-xmpp/protocol/src/identifiers.ts new file mode 100644 index 000000000..a0042f5ee --- /dev/null +++ b/packages/agent-xmpp/protocol/src/identifiers.ts @@ -0,0 +1,165 @@ +const API_VERSION = /^[A-Za-z0-9._~-]{1,64}$/; +const OPAQUE_IDENTIFIER = /^[A-Za-z0-9._~-]{22,128}$/; +const XEP_0082_DATE_TIME = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(?:Z|([+-])(\d{2}):(\d{2}))$/; + +interface Xep0082Instant { + epochSecond: bigint; + fraction: string; +} + +export function isApiVersion(value: string): boolean { + return API_VERSION.test(value); +} + +export function isOpaqueIdentifier(value: string): boolean { + return OPAQUE_IDENTIFIER.test(value); +} + +export function isXep0082DateTime(value: string): boolean { + const match = XEP_0082_DATE_TIME.exec(value); + if (!match) return false; + const [ + , + yearText, + monthText, + dayText, + hourText, + minuteText, + secondText, + , + , + offsetHourText, + offsetMinuteText, + ] = match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const hour = Number(hourText); + const minute = Number(minuteText); + const second = Number(secondText); + if ( + year === 0 || + month < 1 || + month > 12 || + hour > 23 || + minute > 59 || + second > 59 + ) + return false; + const daysInMonth = [ + 31, + isLeapYear(year) ? 29 : 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ][month - 1]!; + if (day < 1 || day > daysInMonth) return false; + if (offsetHourText) { + const offsetHour = Number(offsetHourText); + const offsetMinute = Number(offsetMinuteText); + if ( + offsetHour > 14 || + offsetMinute > 59 || + (offsetHour === 14 && offsetMinute !== 0) + ) + return false; + } + return true; +} + +/** + * Compare represented XEP-0082 instants without truncating fractional seconds. + * Both inputs must already satisfy isXep0082DateTime(). + */ +export function compareXep0082DateTimes(left: string, right: string): number { + const a = parseXep0082Instant(left); + const b = parseXep0082Instant(right); + if (!a || !b) throw new Error("invalid XEP-0082 date-time"); + if (a.epochSecond < b.epochSecond) return -1; + if (a.epochSecond > b.epochSecond) return 1; + const width = Math.max(a.fraction.length, b.fraction.length); + const aFraction = a.fraction.padEnd(width, "0"); + const bFraction = b.fraction.padEnd(width, "0"); + return aFraction < bFraction ? -1 : aFraction > bFraction ? 1 : 0; +} + +export function compareXep0082DateTimeToDate( + value: string, + date: Date, +): number { + return compareXep0082DateTimes(value, date.toISOString()); +} + +/** Smallest integral epoch millisecond that is not before the represented instant. */ +export function xep0082DateTimeToEpochMillisecondsCeil(value: string): number { + const instant = parseXep0082Instant(value); + if (!instant) throw new Error("invalid XEP-0082 date-time"); + const milliseconds = Number(instant.epochSecond) * 1_000; + const firstThreeDigits = Number(instant.fraction.padEnd(3, "0").slice(0, 3)); + const hasSubMillisecondRemainder = /[1-9]/.test(instant.fraction.slice(3)); + return milliseconds + firstThreeDigits + (hasSubMillisecondRemainder ? 1 : 0); +} + +export function isXml10Text(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0)!; + if ( + codePoint !== 0x09 && + codePoint !== 0x0a && + codePoint !== 0x0d && + (codePoint < 0x20 || + (codePoint >= 0xd800 && codePoint <= 0xdfff) || + codePoint === 0xfffe || + codePoint === 0xffff || + codePoint > 0x10ffff) + ) { + return false; + } + } + return true; +} + +export function isToolName(value: string): boolean { + return value.length > 0 && isXml10Text(value); +} + +function isLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function parseXep0082Instant(value: string): Xep0082Instant | null { + if (!isXep0082DateTime(value)) return null; + const match = XEP_0082_DATE_TIME.exec(value)!; + const [ + , + year, + month, + day, + hour, + minute, + second, + fraction = "", + offsetSign, + offsetHour = "0", + offsetMinute = "0", + ] = match; + const date = new Date(0); + date.setUTCFullYear(Number(year), Number(month) - 1, Number(day)); + date.setUTCHours(Number(hour), Number(minute), Number(second), 0); + const signedOffsetSeconds = + (offsetSign === "-" ? -1 : 1) * + (Number(offsetHour) * 60 + Number(offsetMinute)) * + 60; + return { + epochSecond: BigInt(date.getTime() / 1_000 - signedOffsetSeconds), + fraction, + }; +} diff --git a/packages/agent-xmpp/protocol/src/index.ts b/packages/agent-xmpp/protocol/src/index.ts new file mode 100644 index 000000000..87afed84b --- /dev/null +++ b/packages/agent-xmpp/protocol/src/index.ts @@ -0,0 +1,8 @@ +export * from "./namespaces.js"; +export * from "./agent-message.js"; +export * from "./agent-api.js"; +export * from "./agent-task.js"; +export * from "./bridge.js"; +export * from "./identifiers.js"; +export * from "./jid.js"; +export * from "./strict-json.js"; diff --git a/packages/agent-xmpp/protocol/src/jid.ts b/packages/agent-xmpp/protocol/src/jid.ts new file mode 100644 index 000000000..a3eef47cb --- /dev/null +++ b/packages/agent-xmpp/protocol/src/jid.ts @@ -0,0 +1,124 @@ +import { createRequire } from "node:module"; +import { isIP } from "node:net"; +import { readFileSync } from "node:fs"; +import IdnHostname from "idn-hostname"; +import { + initSync, + usernamecasemapped_enforce, +} from "precis-wasm/precis_wasm.js"; + +/** Return the addressable bare JID, stripping any resource suffix. */ +export function bareJid(jid: string): string { + return jid.split("/")[0] ?? jid; +} + +const LOCALPART_EXCLUDED = /["&'/:<>@]/u; +const DOMAINPART_EXCLUDED = /[/@]/u; +const DNS_LABEL_SEPARATOR_AT_END = /[.\u3002\uff0e\uff61]$/u; +const IPV6_ZONE = /^(?:[A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+$/u; +const require = createRequire(import.meta.url); +const { idnHostname, punycode } = IdnHostname; +let precisInitialized = false; + +/** + * Prepare the RFC 7622 bare-JID shape used for ProtoXEP endpoints. + * Endpoint identities require a localpart and deliberately reject resources. + */ +export function normalizeEndpointJid(value: string): string | null { + if ( + value.includes("/") || + value.indexOf("@") <= 0 || + value.indexOf("@") !== value.lastIndexOf("@") + ) + return null; + const [rawLocal, rawDomain] = value.split("@"); + const local = normalizeLocalpart(rawLocal!); + const domain = normalizeDomain(rawDomain!); + if ( + !local || + !domain || + LOCALPART_EXCLUDED.test(local) || + utf8Length(local) > 1023 || + utf8Length(domain) > 1023 + ) { + return null; + } + return `${local}@${domain}`; +} + +export function isNormalizedEndpointJid(value: string): boolean { + return normalizeEndpointJid(value) === value; +} + +export function sameEndpointJid(left: string, right: string): boolean { + const preparedLeft = normalizeEndpointJid(left); + return preparedLeft !== null && preparedLeft === normalizeEndpointJid(right); +} + +function utf8Length(value: string): number { + return new TextEncoder().encode(value).length; +} + +function normalizeLocalpart(value: string): string | null { + initializePrecis(); + try { + return usernamecasemapped_enforce(value) as string; + } catch (error) { + if (error instanceof Error || typeof error === "string") return null; + throw error; + } +} + +function initializePrecis(): void { + if (precisInitialized) return; + const wasmPath = require.resolve("precis-wasm/precis_wasm_bg.wasm"); + initSync({ module: readFileSync(wasmPath) }); + precisInitialized = true; +} + +function normalizeDomain(value: string): string | null { + const withoutFinalSeparator = value.replace(DNS_LABEL_SEPARATOR_AT_END, ""); + if ( + !withoutFinalSeparator || + DOMAINPART_EXCLUDED.test(withoutFinalSeparator) + ) { + return null; + } + + const ipLiteral = normalizeIpLiteral(withoutFinalSeparator); + if (ipLiteral !== undefined) return ipLiteral; + + try { + const ascii = idnHostname(withoutFinalSeparator); + const unicode = punycode + .toUnicode(ascii) + .normalize("NFC") + .toLowerCase() + .normalize("NFC"); + return idnHostname(unicode) === ascii ? unicode : null; + } catch (error) { + if (error instanceof Error) return null; + throw error; + } +} + +function normalizeIpLiteral(value: string): string | null | undefined { + if (!value.startsWith("[") && !value.endsWith("]")) return undefined; + if (!value.startsWith("[") || !value.endsWith("]")) return null; + + const content = value.slice(1, -1); + const zoneDelimiter = content.indexOf("%25"); + const address = + zoneDelimiter === -1 ? content : content.slice(0, zoneDelimiter); + const zone = + zoneDelimiter === -1 ? undefined : content.slice(zoneDelimiter + 3); + if (isIP(address) !== 6 || (zone !== undefined && !IPV6_ZONE.test(zone))) + return null; + + const hostname = new URL(`http://[${address}]/`).hostname.toLowerCase(); + if (zone === undefined) return hostname; + const normalizedZone = zone.replace(/%[0-9A-Fa-f]{2}/gu, (encoded) => + encoded.toUpperCase(), + ); + return `${hostname.slice(0, -1)}%25${normalizedZone}]`; +} diff --git a/packages/agent-xmpp/protocol/src/namespaces.ts b/packages/agent-xmpp/protocol/src/namespaces.ts new file mode 100644 index 000000000..4fbf6218b --- /dev/null +++ b/packages/agent-xmpp/protocol/src/namespaces.ts @@ -0,0 +1,65 @@ +/** ProtoXEP XMPP Agent Gateway 0.0.3 protocol constants. */ +export interface AgentXmppNamespaces { + directory: typeof AGENT_DIRECTORY_NS; + api: typeof AGENT_API_NS; + manifest: typeof AGENT_MANIFEST_FEATURE; + schema: typeof AGENT_SCHEMA_FEATURE; + selfRegister: typeof AGENT_SELF_REGISTER_FEATURE; + admin: typeof AGENT_ADMIN_FEATURE; + tools: typeof AGENT_TOOLS_NS; + tool: typeof AGENT_TOOL_NS; + endpoint: typeof AGENT_ENDPOINT_NS; + endpointInfo: typeof AGENT_ENDPOINT_INFO_FORM; + toolInfo: typeof AGENT_TOOL_INFO_FORM; + task: typeof AGENT_TASK_NS; + progress: typeof AGENT_TASK_PROGRESS_FEATURE; + cancel: typeof AGENT_TASK_CANCEL_FEATURE; + input: typeof AGENT_TASK_INPUT_FEATURE; + hashes: typeof HASHES_NS; + rsm: typeof RSM_NS; +} + +export const AGENT_DIRECTORY_NS = "urn:xmpp:agent-directory:0"; +export const AGENT_API_NS = "urn:xmpp:agent-api:0"; +export const AGENT_MANIFEST_FEATURE = `${AGENT_API_NS}#manifest` as const; +export const AGENT_SCHEMA_FEATURE = `${AGENT_API_NS}#schema` as const; +export const AGENT_SELF_REGISTER_FEATURE = + `${AGENT_API_NS}#self-register` as const; +export const AGENT_ADMIN_FEATURE = `${AGENT_API_NS}#admin` as const; +export const AGENT_TOOLS_NS = "urn:xmpp:agent-tools:0"; +export const AGENT_TOOL_NS = "urn:xmpp:agent-tool:0"; +export const AGENT_ENDPOINT_NS = "urn:xmpp:agent-endpoint:0"; +export const AGENT_ENDPOINT_INFO_FORM = "urn:xmpp:agent-endpoint-info:0"; +export const AGENT_TOOL_INFO_FORM = "urn:xmpp:agent-tool-info:0"; +export const AGENT_TASK_NS = "urn:xmpp:agent-task:0"; +export const AGENT_TASK_PROGRESS_FEATURE = `${AGENT_TASK_NS}#progress` as const; +export const AGENT_TASK_CANCEL_FEATURE = `${AGENT_TASK_NS}#cancel` as const; +export const AGENT_TASK_INPUT_FEATURE = `${AGENT_TASK_NS}#input` as const; +export const HASHES_NS = "urn:xmpp:hashes:2"; +export const RSM_NS = "http://jabber.org/protocol/rsm"; + +export const DEFAULT_PROTOCOL_NAMESPACES: Readonly = + Object.freeze({ + directory: AGENT_DIRECTORY_NS, + api: AGENT_API_NS, + manifest: AGENT_MANIFEST_FEATURE, + schema: AGENT_SCHEMA_FEATURE, + selfRegister: AGENT_SELF_REGISTER_FEATURE, + admin: AGENT_ADMIN_FEATURE, + tools: AGENT_TOOLS_NS, + tool: AGENT_TOOL_NS, + endpoint: AGENT_ENDPOINT_NS, + endpointInfo: AGENT_ENDPOINT_INFO_FORM, + toolInfo: AGENT_TOOL_INFO_FORM, + task: AGENT_TASK_NS, + progress: AGENT_TASK_PROGRESS_FEATURE, + cancel: AGENT_TASK_CANCEL_FEATURE, + input: AGENT_TASK_INPUT_FEATURE, + hashes: HASHES_NS, + rsm: RSM_NS, + }); + +export const AGENT_MANIFEST_SPEC_VERSION = "0"; +export const AGENT_API_SPEC_VERSION = AGENT_MANIFEST_SPEC_VERSION; +export const JSON_MEDIA_TYPE = "application/json"; +export const JSON_SCHEMA_MEDIA_TYPE = "application/schema+json"; diff --git a/packages/agent-xmpp/protocol/src/strict-json.test.ts b/packages/agent-xmpp/protocol/src/strict-json.test.ts new file mode 100644 index 000000000..3bb513132 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/strict-json.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "bun:test"; + +import { parseStrictJson } from "./strict-json.js"; + +describe("parseStrictJson", () => { + it("parses many numeric tokens without copying each remaining suffix", () => { + const values = Array.from({ length: 20_000 }, (_, index) => index % 10); + expect(parseStrictJson(JSON.stringify(values))).toEqual(values); + }); +}); diff --git a/packages/agent-xmpp/protocol/src/strict-json.ts b/packages/agent-xmpp/protocol/src/strict-json.ts new file mode 100644 index 000000000..a69151a5a --- /dev/null +++ b/packages/agent-xmpp/protocol/src/strict-json.ts @@ -0,0 +1,204 @@ +export interface StrictJsonLimits { + maxBytes: number; + maxDepth: number; + maxStringBytes: number; + maxMembers: number; +} + +export const DEFAULT_JSON_LIMITS: Readonly = Object.freeze({ + maxBytes: 1_048_576, + maxDepth: 64, + maxStringBytes: 1_048_576, + maxMembers: 100_000, +}); + +export class JsonResourceLimitError extends Error {} + +const utf8Length = (value: string): number => + new TextEncoder().encode(value).length; +const NUMBER_PATTERN = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y; + +/** A bounded JSON parser that detects duplicate object names before information is lost. */ +export function parseStrictJson( + text: string, + limits: Partial = {}, +): unknown { + const resolved = { ...DEFAULT_JSON_LIMITS, ...limits }; + assertJsonLimits(resolved); + if (utf8Length(text) > resolved.maxBytes) + throw new JsonResourceLimitError("JSON payload exceeds byte limit"); + let offset = 0; + let members = 0; + const fail = (message: string): never => { + throw new Error(`${message} at JSON offset ${offset}`); + }; + const whitespace = (): void => { + while (offset < text.length && /[\t\n\r ]/.test(text[offset]!)) offset++; + }; + const parseString = (): string => { + if (text[offset++] !== '"') return fail("expected string"); + let result = ""; + while (offset < text.length) { + const character = text[offset++]!; + if (character === '"') { + if (utf8Length(result) > resolved.maxStringBytes) { + throw new JsonResourceLimitError( + `JSON string exceeds byte limit at JSON offset ${offset}`, + ); + } + assertUnicodeScalarString(result); + return result; + } + if (character === "\\") { + const escape = text[offset++]!; + const simple: Record = { + '"': '"', + "\\": "\\", + "/": "/", + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: "\t", + }; + if (escape in simple) result += simple[escape]; + else if (escape === "u") { + const hex = text.slice(offset, offset + 4); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail("invalid Unicode escape"); + result += String.fromCharCode(Number.parseInt(hex, 16)); + offset += 4; + } else fail("invalid string escape"); + } else { + if (character.charCodeAt(0) < 0x20) fail("unescaped control character"); + result += character; + } + } + return fail("unterminated string"); + }; + const parseNumber = (): number => { + NUMBER_PATTERN.lastIndex = offset; + const match = NUMBER_PATTERN.exec(text); + if (!match) return fail("invalid number"); + offset = NUMBER_PATTERN.lastIndex; + const number = Number(match[0]); + if (!Number.isFinite(number)) fail("number is not finite"); + if (/^-?[0-9]+$/.test(match[0])) { + const integer = BigInt(match[0]); + if ( + integer > BigInt(Number.MAX_SAFE_INTEGER) || + integer < BigInt(Number.MIN_SAFE_INTEGER) + ) { + fail("integer is outside the lossless range"); + } + } + return number; + }; + const parseValue = (depth: number): unknown => { + if (depth > resolved.maxDepth) { + throw new JsonResourceLimitError( + `JSON nesting exceeds depth limit at JSON offset ${offset}`, + ); + } + whitespace(); + const character = text[offset]; + if (character === '"') return parseString(); + if (character === "{") { + offset++; + const result: Record = {}; + const names = new Set(); + whitespace(); + if (text[offset] === "}") { + offset++; + return result; + } + while (true) { + whitespace(); + if (text[offset] !== '"') fail("expected object member name"); + const name = parseString(); + if (names.has(name)) + fail(`duplicate object member ${JSON.stringify(name)}`); + names.add(name); + if (++members > resolved.maxMembers) { + throw new JsonResourceLimitError( + `JSON member count exceeds limit at JSON offset ${offset}`, + ); + } + whitespace(); + if (text[offset++] !== ":") fail("expected colon"); + result[name] = parseValue(depth + 1); + whitespace(); + const separator = text[offset++]; + if (separator === "}") return result; + if (separator !== ",") fail("expected comma or object end"); + } + } + if (character === "[") { + offset++; + const result: unknown[] = []; + whitespace(); + if (text[offset] === "]") { + offset++; + return result; + } + while (true) { + if (++members > resolved.maxMembers) { + throw new JsonResourceLimitError( + `JSON member count exceeds limit at JSON offset ${offset}`, + ); + } + result.push(parseValue(depth + 1)); + whitespace(); + const separator = text[offset++]; + if (separator === "]") return result; + if (separator !== ",") fail("expected comma or array end"); + } + } + if (text.startsWith("true", offset)) { + offset += 4; + return true; + } + if (text.startsWith("false", offset)) { + offset += 5; + return false; + } + if (text.startsWith("null", offset)) { + offset += 4; + return null; + } + if ( + character === "-" || + (character !== undefined && /[0-9]/.test(character)) + ) + return parseNumber(); + return fail("expected JSON value"); + }; + const value = parseValue(0); + whitespace(); + if (offset !== text.length) fail("trailing data"); + return value; +} + +function assertJsonLimits(limits: StrictJsonLimits): void { + for (const [name, value] of Object.entries(limits)) { + if (!Number.isSafeInteger(value) || value < (name === "maxDepth" ? 0 : 1)) { + throw new Error( + `${name} must be a ${name === "maxDepth" ? "non-negative" : "positive"} safe integer`, + ); + } + } +} + +export function assertUnicodeScalarString(value: string): void { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const low = value.charCodeAt(index + 1); + if (!Number.isInteger(low) || low < 0xdc00 || low > 0xdfff) { + throw new Error("lone high surrogate is not permitted"); + } + index++; + } else if (code >= 0xdc00 && code <= 0xdfff) { + throw new Error("lone low surrogate is not permitted"); + } + } +} diff --git a/packages/agent-xmpp/protocol/tsconfig.json b/packages/agent-xmpp/protocol/tsconfig.json new file mode 100644 index 000000000..5d0f16ada --- /dev/null +++ b/packages/agent-xmpp/protocol/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/auth/package.json b/packages/auth/package.json index ea671ffcd..a1950ebe8 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -11,23 +11,27 @@ "./workspace": "./src/workspace.ts" }, "scripts": { - "auth:generate": "better-auth generate --config src/auth.ts --output ../db/prisma/schema.prisma --y", + "auth:generate": "auth generate --config src/auth.ts --output ../db/prisma/schema.prisma --y", "check-types": "tsc --noEmit", + "oauth:reconcile-client": "bun scripts/reconcile-oauth-client.ts", + "oauth:register-client": "bun scripts/register-oauth-client.ts", "lint": "biome check .", "test": "bun test", "clean": "rm -rf .turbo node_modules" }, "dependencies": { - "@better-auth/api-key": "1.6.25", - "@better-auth/sso": "1.6.25", + "@better-auth/api-key": "1.7.3", + "@better-auth/core": "1.7.3", + "@better-auth/oauth-provider": "1.7.3", + "@better-auth/sso": "1.7.3", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/validation": "workspace:*", - "better-auth": "^1.6.25", - "zod": "^4.4.3" + "better-auth": "1.7.3", + "zod": "^4.5.4" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.8" }, "peerDependenciesMeta": { "react": { @@ -35,11 +39,11 @@ } }, "devDependencies": { - "@better-auth/cli": "^1.4.22", "@crm/typescript-config": "workspace:*", - "@types/node": "^24.10.1", + "@types/node": "^26.5.0", "@types/react": "^19.2.18", + "auth": "1.7.3", "react": "^19.2.8", - "typescript": "5.9.2" + "typescript": "7.0.2" } } diff --git a/packages/auth/scripts/reconcile-oauth-client.ts b/packages/auth/scripts/reconcile-oauth-client.ts new file mode 100644 index 000000000..1a0dd8ebe --- /dev/null +++ b/packages/auth/scripts/reconcile-oauth-client.ts @@ -0,0 +1,7 @@ +import { db } from "@crm/db"; +import { ensureOfficialOAuthClient } from "../src/oauth-client"; + +await ensureOfficialOAuthClient(); +await db.$disconnect(); + +process.stdout.write("Official OAuth client reconciled.\n"); diff --git a/packages/auth/scripts/register-oauth-client.ts b/packages/auth/scripts/register-oauth-client.ts new file mode 100644 index 000000000..b1f8382ec --- /dev/null +++ b/packages/auth/scripts/register-oauth-client.ts @@ -0,0 +1,153 @@ +import { db } from "@crm/db"; +import { z } from "zod"; +import { auth } from "../src/auth"; +import { OAUTH, oauthClientFields } from "../src/oauth-config"; + +function parseOptions(args: string[]) { + const values = new Map(); + const allowed = new Set([ + "--client-id", + "--name", + "--redirect-uri", + "--post-logout-redirect-uri", + ]); + + for (let index = 0; index < args.length; index += 2) { + const key = args[index]; + const value = args[index + 1]; + if (!key || !allowed.has(key) || !value) throw usageError(); + const entries = values.get(key) ?? []; + entries.push(value); + values.set(key, entries); + } + + return z + .object({ + clientId: z + .string() + .trim() + .min(1) + .max(200) + .regex(/^[A-Za-z0-9._~-]+$/), + name: z.string().trim().min(1).max(255), + redirectUris: z.array(redirectUri).min(1), + postLogoutRedirectUris: z.array(redirectUri), + }) + .parse({ + clientId: singleValue(values, "--client-id"), + name: singleValue(values, "--name"), + redirectUris: values.get("--redirect-uri") ?? [], + postLogoutRedirectUris: values.get("--post-logout-redirect-uri") ?? [], + }); +} + +const redirectUri = z.string().superRefine((value, context) => { + let uri: URL; + + try { + uri = new URL(value); + } catch { + context.addIssue({ code: "custom", message: "Redirect URI is invalid." }); + return; + } + + if (uri.hash) { + context.addIssue({ + code: "custom", + message: "Redirect URI contains a fragment.", + }); + } + + if (value.includes("*")) { + context.addIssue({ + code: "custom", + message: "Redirect URI contains a wildcard.", + }); + } + + if (uri.username || uri.password) { + context.addIssue({ + code: "custom", + message: "Redirect URI contains user information.", + }); + } + + if (uri.protocol === "https:" && uri.hostname) return; + + if ( + uri.protocol === "http:" && + ["127.0.0.1", "[::1]", "localhost"].includes(uri.hostname) + ) { + return; + } + + if (uri.protocol.slice(0, -1).includes(".") && !uri.host) return; + + context.addIssue({ + code: "custom", + message: + "Redirect URI must use HTTPS, loopback HTTP, or a reverse-domain private scheme.", + }); +}); + +function singleValue(values: Map, key: string) { + const entries = values.get(key); + if (entries?.length !== 1) throw usageError(); + return entries[0]; +} + +function usageError() { + return new Error( + "Usage: bun run oauth:register-client --client-id --name --redirect-uri [--redirect-uri ] [--post-logout-redirect-uri ]", + ); +} + +const options = parseOptions(process.argv.slice(2)); + +await auth.$context; + +if (options.clientId === OAUTH.officialClient.id) { + throw new Error( + "The official OAuth client is managed by the reconciliation command.", + ); +} + +const existing = await db.oauthClient.findUnique({ + where: { clientId: options.clientId }, + select: { clientId: true }, +}); + +if (existing) + throw new Error(`OAuth client ${options.clientId} already exists.`); + +const now = new Date(); + +await db.$transaction(async (transaction) => { + await transaction.oauthClient.create({ + data: { + id: options.clientId, + ...oauthClientFields({ + clientId: options.clientId, + name: options.name, + redirectUris: options.redirectUris, + postLogoutRedirectUris: options.postLogoutRedirectUris, + skipConsent: false, + }), + createdAt: now, + updatedAt: now, + }, + }); + + await transaction.oauthClientResource.create({ + data: { + id: `oauth-client-resource:${options.clientId}`, + clientId: options.clientId, + resourceId: OAUTH.resource, + createdAt: now, + }, + }); +}); + +await db.$disconnect(); + +process.stdout.write(`Registered OAuth client ${options.clientId}.\n`); diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index c59c98254..6be0d02a1 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -1,16 +1,21 @@ import { apiKey } from "@better-auth/api-key"; +import { oauthProvider } from "@better-auth/oauth-provider"; import { sso } from "@better-auth/sso"; import { db } from "@crm/db"; +import { scopedDb } from "@crm/db/tenant-scope"; import { schemas } from "@crm/validation"; +import { parseActiveOrganizationClaim } from "@crm/validation/active-organization-claim"; import { type BetterAuthOptions, betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { APIError } from "better-auth/api"; import { genericOAuth } from "better-auth/plugins/generic-oauth"; +import { jwt } from "better-auth/plugins/jwt"; import { organization } from "better-auth/plugins/organization"; import { API_KEY_EXPIRATION, API_KEY_HEADER, API_KEY_PREFIX } from "./api-keys"; import { AUTH_COOKIE_PREFIX } from "./cookies"; import { env } from "./env"; -import { ensureWorkspaceMembership } from "./organization"; +import { OAUTH, OAUTH_ORGANIZATION_CLAIM, OAUTH_SCOPES } from "./oauth-config"; +import { resolveActiveOrganization, workspaceRoleOf } from "./organization"; import { GOOGLE_PROVIDER_ID, MICROSOFT_PROVIDER_ID, @@ -24,6 +29,7 @@ import { rememberSlackInstall, replaceSlackConnection } from "./slack-grant"; import { SLACK_REQUESTED_SCOPES, SLACK_USER_SCOPES } from "./slack-scopes"; import { queueSlackInventorySync } from "./slack-sync"; import { + googleHostedDomain, hasSignInAllowList, isWorkspaceEmail, primaryWorkspaceDomain, @@ -32,7 +38,7 @@ import { const socialProviders: NonNullable = {}; const slackOAuth = env.slack; const slackRedirectUri = new URL( - "/api/auth/oauth2/callback/slack", + "/api/auth/callback/slack", env.apiUrl, ).toString(); @@ -45,7 +51,7 @@ if (env.google) { accessType: "offline", }; - const hostedDomain = primaryWorkspaceDomain(); + const hostedDomain = googleHostedDomain(); if (hostedDomain) google.hd = hostedDomain; socialProviders.google = google; @@ -72,8 +78,9 @@ if (env.microsoft) { export const auth = betterAuth({ appName: "CRM", baseURL: env.apiUrl, + disabledPaths: ["/token"], - database: prismaAdapter(db, { + database: prismaAdapter(scopedDb, { provider: "postgresql", }), @@ -122,6 +129,60 @@ export const auth = betterAuth({ }, plugins: [ + jwt({ + disableSettingJwtHeader: true, + jwt: { + issuer: OAUTH.issuer, + audience: OAUTH.resource, + expirationTime: `${OAUTH.accessTokenTtlSeconds}s`, + }, + }), + oauthProvider({ + loginPage: OAUTH.loginPage, + consentPage: OAUTH.consentPage, + postLogin: { + page: OAUTH.consentPage, + shouldRedirect: async ({ user, session }) => { + await oauthOrganizationId( + user.id, + parseActiveOrganizationClaim(session.activeOrganizationId), + ); + return false; + }, + consentReferenceId: ({ user, session }) => + oauthOrganizationId( + user.id, + parseActiveOrganizationClaim(session.activeOrganizationId), + ), + }, + customAccessTokenClaims: ({ referenceId }) => + referenceId ? { [OAUTH_ORGANIZATION_CLAIM]: referenceId } : {}, + scopes: [...OAUTH_SCOPES], + resources: [ + { + identifier: OAUTH.resource, + name: "CompCRM API", + accessTokenTtl: OAUTH.accessTokenTtlSeconds, + refreshTokenTtl: OAUTH.refreshTokenTtlSeconds, + allowedScopes: [...OAUTH_SCOPES], + }, + ], + resourceSeedMode: "overwrite", + cachedResources: new Set([OAUTH.resource]), + enforcePerClientResources: true, + clientRegistrationDefaultResources: [OAUTH.resource], + cachedTrustedClients: new Set([OAUTH.officialClient.id]), + accessTokenExpiresIn: OAUTH.accessTokenTtlSeconds, + idTokenExpiresIn: OAUTH.idTokenTtlSeconds, + refreshTokenExpiresIn: OAUTH.refreshTokenTtlSeconds, + refreshTokenReuseInterval: OAUTH.refreshTokenReuseIntervalSeconds, + codeExpiresIn: OAUTH.authorizationCodeTtlSeconds, + grantTypes: ["authorization_code", "refresh_token"], + allowDynamicClientRegistration: false, + allowUnauthenticatedClientRegistration: false, + clientPrivileges: () => false, + resourcePrivileges: () => false, + }), ...(slackOAuth ? [ genericOAuth({ @@ -234,7 +295,8 @@ export const auth = betterAuth({ apiKey({ apiKeyHeaders: API_KEY_HEADER, defaultPrefix: API_KEY_PREFIX, - enableSessionForAPIKeys: true, + enableMetadata: true, + references: "organization", requireName: true, defaultKeyLength: 32, maximumNameLength: 64, @@ -283,10 +345,12 @@ export const auth = betterAuth({ session: { create: { before: async (session) => { - const workspaceId = await ensureWorkspaceMembership(session.userId); + const activeOrganizationId = await resolveActiveOrganization( + session.userId, + ); return { - data: { ...session, activeOrganizationId: workspaceId ?? null }, + data: { ...session, activeOrganizationId }, }; }, @@ -304,7 +368,32 @@ export const auth = betterAuth({ }); export type Auth = typeof auth; -export type Session = typeof auth.$Infer.Session; +type BetterAuthSession = typeof auth.$Infer.Session; + +async function oauthOrganizationId( + userId: string, + activeOrganizationId: string | null, +): Promise { + if (!activeOrganizationId) { + throw new APIError("FORBIDDEN", { + message: "Select an organization before authorizing this application.", + }); + } + + if (!(await workspaceRoleOf(userId, activeOrganizationId))) { + throw new APIError("FORBIDDEN", { + message: "You are not a member of the selected organization.", + }); + } + + return activeOrganizationId; +} + +export type Session = Omit & { + session: BetterAuthSession["session"] & { + activeOrganizationId: string | null; + }; +}; export type SessionUser = Session["user"]; async function replaceSlackAccount(account: { diff --git a/packages/auth/src/client.ts b/packages/auth/src/client.ts index 980a37b67..59c499687 100644 --- a/packages/auth/src/client.ts +++ b/packages/auth/src/client.ts @@ -1,11 +1,17 @@ import { apiKeyClient } from "@better-auth/api-key/client"; +import { oauthProviderClient } from "@better-auth/oauth-provider/client"; import { ssoClient } from "@better-auth/sso/client"; -import { genericOAuthClient } from "better-auth/client/plugins"; +import { organizationClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ baseURL: globalThis.window?.location.origin, - plugins: [ssoClient(), genericOAuthClient(), apiKeyClient()], + plugins: [ + ssoClient(), + apiKeyClient(), + oauthProviderClient(), + organizationClient(), + ], }); export const { getSession, signIn, signOut, useSession } = authClient; diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 31fbcbaad..3128dbea7 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -13,18 +13,36 @@ export { isMicrosoftConfigured, isSlackConfigured, } from "./env"; +export { ensureOfficialOAuthClient } from "./oauth-client"; export { + isOAuthScope, + OAUTH, + OAUTH_ORGANIZATION_CLAIM, + OAUTH_SCOPES, + type OAuthScope, + oauthClientFields, +} from "./oauth-config"; +export { + getProtectedResourceMetadata, + verifyAccessTokenRequest, +} from "./oauth-resource"; +export { + bearerChallenge, + oauthScopeFailure, + requiredCrmScope, +} from "./oauth-scope"; +export { + activeOrganizationIdOf, + activeWorkspaceRoleOf, canChangeRole, canManageConnections, canManageCurrency, canManageTracking, canRenameWorkspace, - DEFAULT_WORKSPACE_NAME, - ensureWorkspaceMembership, isWorkspaceAdmin, isWorkspaceRole, + resolveActiveOrganization, toWorkspaceRole, - WORKSPACE_ID, WORKSPACE_ROLES, type WorkspaceRole, workspaceRoleOf, @@ -72,6 +90,11 @@ export { ssoCallbackURL, ssoProviderName, } from "./sso"; +export { + resolveSsoRequestOrganizationId, + runSsoRequestInTenant, + type SsoTenantRequest, +} from "./sso-tenant-context"; export { hasSignInAllowList, isWorkspaceEmail, diff --git a/packages/auth/src/oauth-client.ts b/packages/auth/src/oauth-client.ts new file mode 100644 index 000000000..4fc16183e --- /dev/null +++ b/packages/auth/src/oauth-client.ts @@ -0,0 +1,48 @@ +import { db } from "@crm/db"; +import { auth } from "./auth"; +import { OAUTH, oauthClientFields } from "./oauth-config"; + +const OFFICIAL_CLIENT_RESOURCE_ID = "compcrm-flutter-resource"; + +export async function ensureOfficialOAuthClient(): Promise { + await auth.$context; + const now = new Date(); + const clientId = OAUTH.officialClient.id; + const clientFields = oauthClientFields({ + clientId, + name: OAUTH.officialClient.name, + redirectUris: OAUTH.officialClient.redirectUris, + postLogoutRedirectUris: OAUTH.officialClient.postLogoutRedirectUris, + skipConsent: true, + }); + + await db.$transaction(async (transaction) => { + await transaction.oauthClient.upsert({ + where: { clientId }, + create: { + id: clientId, + ...clientFields, + createdAt: now, + updatedAt: now, + }, + update: { + ...clientFields, + updatedAt: now, + }, + }); + + await transaction.oauthClientResource.upsert({ + where: { id: OFFICIAL_CLIENT_RESOURCE_ID }, + create: { + id: OFFICIAL_CLIENT_RESOURCE_ID, + clientId, + resourceId: OAUTH.resource, + createdAt: now, + }, + update: { + clientId, + resourceId: OAUTH.resource, + }, + }); + }); +} diff --git a/packages/auth/src/oauth-config.ts b/packages/auth/src/oauth-config.ts new file mode 100644 index 000000000..ab16c3d85 --- /dev/null +++ b/packages/auth/src/oauth-config.ts @@ -0,0 +1,72 @@ +import { DAY_SECONDS } from "./api-keys"; +import { apiUrl, appUrl } from "./env"; + +const MINUTE_SECONDS = 60; +const OAUTH_RESOURCE = new URL("/api", apiUrl).toString(); + +export const OAUTH_ORGANIZATION_CLAIM = `${OAUTH_RESOURCE}/claims/organization_id`; + +export const OAUTH = { + issuer: new URL("/api/auth", apiUrl).toString(), + resource: OAUTH_RESOURCE, + loginPage: new URL("/sign-in", appUrl).toString(), + consentPage: new URL("/oauth/consent", appUrl).toString(), + accessTokenTtlSeconds: 10 * MINUTE_SECONDS, + idTokenTtlSeconds: 10 * MINUTE_SECONDS, + authorizationCodeTtlSeconds: 10 * MINUTE_SECONDS, + refreshTokenTtlSeconds: 30 * DAY_SECONDS, + refreshTokenReuseIntervalSeconds: 30, + scopes: { + identity: ["openid", "profile", "email", "offline_access"], + crm: { + read: "crm.read", + write: "crm.write", + }, + }, + officialClient: { + id: "compcrm-flutter", + name: "CompCRM for Flutter", + redirectUris: ["ai.trycrm.app:/oauth/callback"], + postLogoutRedirectUris: ["ai.trycrm.app:/oauth/logout"], + }, +} as const; + +export const OAUTH_SCOPES = [ + ...OAUTH.scopes.identity, + OAUTH.scopes.crm.read, + OAUTH.scopes.crm.write, +] as const; + +export type OAuthScope = (typeof OAUTH_SCOPES)[number]; + +export function isOAuthScope(value: string): value is OAuthScope { + return (OAUTH_SCOPES as readonly string[]).includes(value); +} + +export function oauthClientFields(input: { + clientId: string; + name: string; + redirectUris: readonly string[]; + postLogoutRedirectUris: readonly string[]; + skipConsent: boolean; +}) { + return { + clientId: input.clientId, + clientSecret: null, + disabled: false, + skipConsent: input.skipConsent, + enableEndSession: true, + scopes: [...OAUTH_SCOPES], + clientCredentialsScopes: [], + name: input.name, + contacts: [], + redirectUris: [...input.redirectUris], + postLogoutRedirectUris: [...input.postLogoutRedirectUris], + tokenEndpointAuthMethod: "none", + applicationType: "native", + grantTypes: ["authorization_code", "refresh_token"], + responseTypes: ["code"], + requirePKCE: true, + dpopBoundAccessTokens: false, + }; +} diff --git a/packages/auth/src/oauth-resource.ts b/packages/auth/src/oauth-resource.ts new file mode 100644 index 000000000..3611b76d6 --- /dev/null +++ b/packages/auth/src/oauth-resource.ts @@ -0,0 +1,45 @@ +import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client"; +import { APIError } from "better-auth/api"; +import type { ResourceRequestInput } from "better-auth/oauth2"; +import { verifyJwsAccessToken } from "better-auth/oauth2"; +import { auth } from "./auth"; +import { OAUTH } from "./oauth-config"; + +const oauthResource = oauthProviderResourceClient(auth).getActions(); +const jwksCacheKey = {}; + +type VerifyAccessTokenRequestOptions = { + verifyOptions?: { + issuer?: string | string[]; + audience?: string | string[]; + }; +}; + +export const getProtectedResourceMetadata = + oauthResource.getProtectedResourceMetadata; + +export async function verifyAccessTokenRequest( + request: Request | ResourceRequestInput, + opts?: VerifyAccessTokenRequestOptions, +) { + const authorization = + request instanceof Request + ? request.headers.get("authorization") + : request.authorizationHeader; + const match = authorization?.match(/^Bearer\s+(.+)$/i); + if (!match?.[1]) { + throw new APIError("UNAUTHORIZED", { + message: "A bearer access token is required.", + }); + } + + return verifyJwsAccessToken(match[1], { + jwksFetch: () => auth.api.getJwks(), + jwksCacheKey, + verifyOptions: { + issuer: OAUTH.issuer, + audience: OAUTH.resource, + ...opts?.verifyOptions, + }, + }); +} diff --git a/packages/auth/src/oauth-scope.ts b/packages/auth/src/oauth-scope.ts new file mode 100644 index 000000000..3fbb8366f --- /dev/null +++ b/packages/auth/src/oauth-scope.ts @@ -0,0 +1,22 @@ +import { OAUTH } from "./oauth-config"; + +export function requiredCrmScope(write: boolean): string { + return write ? OAUTH.scopes.crm.write : OAUTH.scopes.crm.read; +} + +export function bearerChallenge(error?: string, scope?: string): string { + const errorPart = error ? `, error="${error}"` : ""; + const scopePart = scope ? `, scope="${scope}"` : ""; + return `Bearer realm="compcrm"${errorPart}${scopePart}`; +} + +export function oauthScopeFailure( + scopes: ReadonlySet, + requiredScope: string, +): { challenge: string; message: string } | null { + if (scopes.has(requiredScope)) return null; + return { + challenge: bearerChallenge("insufficient_scope", requiredScope), + message: `The token requires ${requiredScope}.`, + }; +} diff --git a/packages/auth/src/organization.ts b/packages/auth/src/organization.ts index df863e2f3..6342ecaa4 100644 --- a/packages/auth/src/organization.ts +++ b/packages/auth/src/organization.ts @@ -1,9 +1,6 @@ import { type Db, db } from "@crm/db"; -import { WORKSPACE_ID, workspaceSlug } from "@crm/db/workspace"; - -export { WORKSPACE_ID }; - -export const DEFAULT_WORKSPACE_NAME = "CRM"; +import { currentOrganizationId } from "@crm/db/tenant-context"; +import { parseActiveOrganizationClaim } from "@crm/validation/active-organization-claim"; export const WORKSPACE_ROLES = ["owner", "admin", "member"] as const; @@ -37,79 +34,99 @@ export function canManageTracking(role: WorkspaceRole | null): boolean { return isWorkspaceAdmin(role); } -export async function ensureWorkspaceMembership( +export function activeOrganizationIdOf( + session: { + session: { activeOrganizationId: unknown }; + } | null, +): string | null { + return parseActiveOrganizationClaim(session?.session.activeOrganizationId); +} + +export async function resolveActiveOrganization( userId: string, -): Promise { - try { - return await db.$transaction(async (tx) => { - const workspace = await tx.organization.upsert({ - where: { id: WORKSPACE_ID }, - create: { - id: WORKSPACE_ID, - name: DEFAULT_WORKSPACE_NAME, - slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), - createdAt: new Date(), - }, - update: {}, - select: { id: true, name: true, slug: true }, - }); +): Promise { + const membership = await db.member.findFirst({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: { organizationId: true }, + }); - const slug = workspaceSlug(workspace.name); + if (!membership) return null; + await syncKaneoMembership(userId, membership.organizationId); + return membership.organizationId; +} - if (workspace.slug !== slug) { - await tx.organization.update({ - where: { id: workspace.id }, - data: { slug }, - }); +async function syncKaneoMembership( + userId: string, + organizationId: string, +): Promise { + try { + await db.$transaction(async (tx) => { + const workspace = await tx.organization.findUnique({ + where: { id: organizationId }, + select: { name: true, slug: true, createdAt: true }, + }); + if (!workspace) { + return; } - const enrolled = await tx.member.count({ - where: { organizationId: workspace.id }, + await tx.workspace.upsert({ + where: { id: organizationId }, + create: { + id: organizationId, + name: workspace.name, + slug: workspace.slug, + createdAt: workspace.createdAt, + }, + update: { name: workspace.name, slug: workspace.slug }, }); - if (enrolled === 0) { - const existing = await tx.user.findMany({ - select: { id: true }, - orderBy: [{ createdAt: "asc" }, { id: "asc" }], - }); - - await tx.member.createMany({ - data: existing.map((user, index) => ({ - id: crypto.randomUUID(), - organizationId: workspace.id, - userId: user.id, - role: index === 0 ? "owner" : "member", - createdAt: new Date(), - })), - skipDuplicates: true, - }); - } - - await tx.member.upsert({ + const membership = await tx.member.findUnique({ where: { - organizationId_userId: { organizationId: workspace.id, userId }, + organizationId_userId: { organizationId, userId }, }, - create: { + select: { role: true }, + }); + const role = toKaneoRole(toWorkspaceRole(membership?.role ?? "member")); + + const existing = await tx.workspaceMember.findFirst({ + where: { workspaceId: organizationId, userId }, + select: { id: true, role: true }, + }); + if (existing) { + if (existing.role !== role) { + await tx.workspaceMember.update({ + where: { id: existing.id }, + data: { role }, + }); + } + return; + } + await tx.workspaceMember.create({ + data: { id: crypto.randomUUID(), - organizationId: workspace.id, + workspaceId: organizationId, userId, - role: "member", - createdAt: new Date(), + role, + joinedAt: new Date(), }, - update: {}, }); - - return workspace.id; }); } catch (error) { console.error( - `[auth] could not enrol user ${userId} in workspace ${WORKSPACE_ID}; the next sign-in will retry`, + `[auth] could not sync user ${userId} into the kaneo workspace ${organizationId}; the next sign-in will retry`, error, ); - return undefined; } } +function toKaneoRole(role: WorkspaceRole): string { + if (role === "owner" || role === "admin") { + return "admin"; + } + return "member"; +} + export function toWorkspaceRole(value: string): WorkspaceRole { return isWorkspaceRole(value) ? value : "member"; } @@ -118,12 +135,20 @@ export type WorkspaceMemberReader = Pick; export async function workspaceRoleOf( userId: string, + organizationId: string, client: WorkspaceMemberReader = db, ): Promise { const member = await client.member.findUnique({ - where: { organizationId_userId: { organizationId: WORKSPACE_ID, userId } }, + where: { organizationId_userId: { organizationId, userId } }, select: { role: true }, }); return member ? toWorkspaceRole(member.role) : null; } + +export async function activeWorkspaceRoleOf( + userId: string, + client: WorkspaceMemberReader = db, +): Promise { + return workspaceRoleOf(userId, currentOrganizationId(), client); +} diff --git a/packages/auth/src/slack-connect.ts b/packages/auth/src/slack-connect.ts index f8fa9a692..d3eb9e2b4 100644 --- a/packages/auth/src/slack-connect.ts +++ b/packages/auth/src/slack-connect.ts @@ -8,7 +8,6 @@ import { import * as z from "zod"; import { canManageConnections, - WORKSPACE_ID, WORKSPACE_ROLES, workspaceRoleOf, } from "./organization"; @@ -18,16 +17,25 @@ const CONNECT_MANAGER_ROLES = WORKSPACE_ROLES.filter((role) => canManageConnections(role), ); -const SLACK_CONNECT_START_PATHS = ["/oauth2/link", "/sign-in/oauth2"]; -const OAUTH_CALLBACK_PATH = "/oauth2/callback"; +const SLACK_CONNECT_START_PATH = "/link-social"; +const OAUTH_CALLBACK_PATH = "/callback"; -const connectStartBody = z.object({ providerId: z.string() }); -const callbackParams = z.object({ providerId: z.string() }); +const connectStartBody = z.object({ provider: z.string() }); +const callbackParams = z.object({ id: z.string() }); +const callbackQuery = z.object({ state: z.string() }); +const oauthState = z.object({ + link: z + .object({ + email: z.string(), + userId: z.string(), + }) + .optional(), +}); export const slackConnectGuard = createAuthMiddleware(async (ctx) => { const guarded = startsSlackConnect(ctx.path, ctx.body) || - completesSlackConnect(ctx.path, ctx.params); + (await completesSlackConnect(ctx.path, ctx.params, ctx.query)); if (!guarded) return; const session = await getSessionFromCtx(ctx, { disableCookieCache: true }); @@ -36,12 +44,18 @@ export const slackConnectGuard = createAuthMiddleware(async (ctx) => { message: "Sign in to the CRM before you connect Slack.", }); } + const organizationId = session.session.activeOrganizationId; + if (!organizationId) { + throw new APIError("FORBIDDEN", { + message: "Join an organization before you connect Slack.", + }); + } const [role, managers] = await Promise.all([ - workspaceRoleOf(session.user.id), + workspaceRoleOf(session.user.id, organizationId), db.member.count({ where: { - organizationId: WORKSPACE_ID, + organizationId, role: { in: [...CONNECT_MANAGER_ROLES] }, }, }), @@ -62,14 +76,36 @@ export const slackConnectGuard = createAuthMiddleware(async (ctx) => { } }); -function startsSlackConnect(path: string, body: JsonValue): boolean { - if (!SLACK_CONNECT_START_PATHS.includes(path)) return false; +function startsSlackConnect( + path: string, + body: JsonValue | undefined, +): boolean { + if (path !== SLACK_CONNECT_START_PATH) return false; const parsed = connectStartBody.safeParse(body); - return parsed.success && parsed.data.providerId === SLACK_PROVIDER_ID; + return parsed.success && parsed.data.provider === SLACK_PROVIDER_ID; } -function completesSlackConnect(path: string, params: JsonValue): boolean { +async function completesSlackConnect( + path: string, + params: JsonValue | undefined, + query: JsonValue | undefined, +): Promise { if (!path.startsWith(OAUTH_CALLBACK_PATH)) return false; - const parsed = callbackParams.safeParse(params); - return parsed.success && parsed.data.providerId === SLACK_PROVIDER_ID; + const parsedParams = callbackParams.safeParse(params); + if (!parsedParams.success || parsedParams.data.id !== SLACK_PROVIDER_ID) { + return false; + } + const parsedQuery = callbackQuery.safeParse(query); + if (!parsedQuery.success) return false; + const verification = await db.verification.findFirst({ + where: { identifier: parsedQuery.data.state }, + select: { value: true }, + }); + if (!verification) return false; + try { + const parsedState = oauthState.safeParse(JSON.parse(verification.value)); + return parsedState.success && parsedState.data.link !== undefined; + } catch { + return false; + } } diff --git a/packages/auth/src/slack-grant.ts b/packages/auth/src/slack-grant.ts index c1e3f0338..41aa099cf 100644 --- a/packages/auth/src/slack-grant.ts +++ b/packages/auth/src/slack-grant.ts @@ -1,72 +1,114 @@ -import { db } from "@crm/db"; +import { getCurrentAuthContext } from "@better-auth/core/context"; +import type { Prisma } from "@crm/db"; import { lockIdempotencyKey } from "@crm/db/idempotency"; +import { + runInTenant, + TenantContextError, + tryCurrentOrganizationId, +} from "@crm/db/tenant-context"; +import { scopedTransaction } from "@crm/db/tenant-scope"; import type { OauthAccess } from "@crm/validation"; -import { SLACK_PROVIDER_ID } from "./scopes"; +import { parseActiveOrganizationClaim } from "@crm/validation/active-organization-claim"; +import { getSessionFromCtx } from "better-auth/api"; import { SLACK_CONNECTION } from "./slack-config"; export async function rememberSlackInstall(grant: OauthAccess): Promise { const { team, authed_user: installer } = grant; if (!team || !installer) return; + const organizationId = await currentSlackOrganizationId(); + const install = { teamId: team.id, teamName: team.name ?? null, + botToken: grant.access_token ?? null, + botScopes: grant.scope ?? "", userToken: installer.access_token ?? null, userScopes: installer.scope ?? "", createdAt: new Date(), }; - await db.slackInstallation.upsert({ - where: { installerId: installer.id }, - create: { installerId: installer.id, ...install }, - update: install, - }); + await runInTenant(organizationId, () => + scopedTransaction(async (tx) => { + await tx.slackInstallation.upsert({ + where: { + organizationId_installerId: { + organizationId, + installerId: installer.id, + }, + }, + create: { installerId: installer.id, ...install }, + update: install, + }); - await forgetStaleInstalls(); + await forgetStaleInstalls(tx); + }), + ); } export async function replaceSlackConnection(account: { - id: string; accountId: string; }): Promise { - await db.$transaction(async (tx) => { - await lockIdempotencyKey(tx, SLACK_CONNECTION.locks.connection); - - await tx.account.deleteMany({ - where: { providerId: SLACK_PROVIDER_ID, id: { not: account.id } }, - }); - - const install = await tx.slackInstallation.findUnique({ - where: { installerId: account.accountId }, - }); - if (!install) return; - - await tx.slackInstallation.delete({ - where: { installerId: account.accountId }, - }); - - await tx.slackWorkspaceGrant.deleteMany({ - where: { teamId: { not: install.teamId } }, - }); - - if (!install.userToken) return; - - const grant = { - teamName: install.teamName, - userToken: install.userToken, - userScopes: install.userScopes, - }; - - await tx.slackWorkspaceGrant.upsert({ - where: { teamId: install.teamId }, - create: { teamId: install.teamId, ...grant }, - update: grant, - }); - }); + const organizationId = await currentSlackOrganizationId(); + + await runInTenant(organizationId, () => + scopedTransaction(async (tx) => { + await lockIdempotencyKey( + tx, + `${SLACK_CONNECTION.locks.connection}:${organizationId}`, + ); + + const install = await tx.slackInstallation.findUnique({ + where: { + organizationId_installerId: { + organizationId, + installerId: account.accountId, + }, + }, + }); + if (!install) return; + + await tx.slackInstallation.delete({ + where: { + organizationId_installerId: { + organizationId, + installerId: account.accountId, + }, + }, + }); + + await tx.slackWorkspaceGrant.deleteMany({ + where: { teamId: { not: install.teamId } }, + }); + + if (!install.userToken) return; + + const grant = { + teamName: install.teamName, + botToken: install.botToken, + botScopes: install.botScopes, + userToken: install.userToken, + userScopes: install.userScopes, + }; + + await tx.slackWorkspaceGrant.upsert({ + where: { + organizationId_teamId: { + organizationId, + teamId: install.teamId, + }, + }, + create: { teamId: install.teamId, ...grant }, + update: grant, + }); + }), + ); } -async function forgetStaleInstalls(): Promise { - await db.slackInstallation.deleteMany({ +async function forgetStaleInstalls( + tx: Prisma.TransactionClient, +): Promise { + await tx.slackInstallation.deleteMany({ where: { createdAt: { lt: new Date(Date.now() - SLACK_CONNECTION.install.staleMs), @@ -74,3 +116,24 @@ async function forgetStaleInstalls(): Promise { }, }); } + +async function currentSlackOrganizationId(): Promise { + const organizationId = tryCurrentOrganizationId(); + if (organizationId) return organizationId; + + const context = await getCurrentAuthContext().catch(() => { + throw new TenantContextError(); + }); + const session = await getSessionFromCtx( + context as Parameters[0], + { + disableCookieCache: true, + }, + ); + const activeOrganizationId = parseActiveOrganizationClaim( + session?.session.activeOrganizationId, + ); + + if (!activeOrganizationId) throw new TenantContextError(); + return activeOrganizationId; +} diff --git a/packages/auth/src/sso-tenant-context.ts b/packages/auth/src/sso-tenant-context.ts new file mode 100644 index 000000000..475562658 --- /dev/null +++ b/packages/auth/src/sso-tenant-context.ts @@ -0,0 +1,128 @@ +import { db } from "@crm/db"; +import { runInTenant } from "@crm/db/tenant-context"; +import { z } from "zod"; + +const ssoRoutingBody = z + .object({ + domain: z.string().optional(), + email: z.string().optional(), + organizationId: z.string().optional(), + organizationSlug: z.string().optional(), + providerId: z.string().optional(), + }) + .loose(); + +export interface SsoTenantRequest { + body?: unknown; + originalUrl?: string; + url?: string; +} + +export async function runSsoRequestInTenant( + request: SsoTenantRequest, + next: () => T | PromiseLike, +): Promise { + const organizationId = await resolveSsoRequestOrganizationId(request); + + if (!organizationId) { + return Promise.resolve(next()); + } + + return runInTenant(organizationId, next); +} + +export async function resolveSsoRequestOrganizationId( + request: SsoTenantRequest, +): Promise { + const url = requestUrl(request); + const path = url.pathname; + + if (!isSsoPath(path)) { + return undefined; + } + + const pathProviderId = providerIdFromPath(path); + if (pathProviderId) { + return organizationForProvider(pathProviderId); + } + + const parsedBody = ssoRoutingBody.safeParse(request.body); + const body = parsedBody.success ? parsedBody.data : undefined; + const providerId = body?.providerId ?? url.searchParams.get("providerId"); + if (providerId) { + return organizationForProvider(providerId); + } + + if (path.endsWith("/sso/register") && body?.organizationId) { + return body.organizationId; + } + + if (!path.endsWith("/sign-in/sso")) { + return undefined; + } + + if (body?.organizationSlug) { + const organization = await db.organization.findUnique({ + where: { slug: body.organizationSlug }, + select: { id: true }, + }); + return organization?.id; + } + + const domain = body?.domain ?? body?.email?.split("@")[1]; + if (!domain) { + return undefined; + } + + const locators = await db.ssoProviderLocator.findMany({ + select: { organizationId: true, domain: true }, + }); + return locators.find((locator) => domainMatches(domain, locator.domain)) + ?.organizationId; +} + +function requestUrl(request: SsoTenantRequest): URL { + return new URL(request.originalUrl ?? request.url ?? "/", "http://localhost"); +} + +function isSsoPath(path: string): boolean { + return path.includes("/sso/") || path.endsWith("/sign-in/sso"); +} + +function providerIdFromPath(path: string): string | undefined { + const match = path.match( + /\/sso\/(?:callback|saml2\/sp\/(?:acs|slo)|saml2\/logout)\/([^/]+)\/?$/, + ); + if (!match?.[1]) { + return undefined; + } + + try { + return decodeURIComponent(match[1]); + } catch { + return undefined; + } +} + +async function organizationForProvider( + providerId: string, +): Promise { + const locator = await db.ssoProviderLocator.findUnique({ + where: { providerId }, + select: { organizationId: true }, + }); + return locator?.organizationId; +} + +function domainMatches(searchDomain: string, domainList: string): boolean { + const search = searchDomain.trim().toLowerCase(); + if (!search) { + return false; + } + + return domainList + .split(",") + .map((domain) => domain.trim().toLowerCase()) + .filter(Boolean) + .some((domain) => search === domain || search.endsWith(`.${domain}`)); +} diff --git a/packages/auth/src/workspace.ts b/packages/auth/src/workspace.ts index d94bc1c20..d464da136 100644 --- a/packages/auth/src/workspace.ts +++ b/packages/auth/src/workspace.ts @@ -36,6 +36,12 @@ export function primaryWorkspaceDomain(): string | undefined { return allowList().domains[0]; } +export function googleHostedDomain(): string | undefined { + const { domains, addresses } = allowList(); + if (domains.length !== 1 || addresses.length > 0) return undefined; + return domains[0]; +} + export function hasSignInAllowList(): boolean { const { domains, addresses } = allowList(); return domains.length > 0 || addresses.length > 0; diff --git a/packages/auth/test/oauth-client-fields.spec.ts b/packages/auth/test/oauth-client-fields.spec.ts new file mode 100644 index 000000000..a3e374802 --- /dev/null +++ b/packages/auth/test/oauth-client-fields.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "bun:test"; + +process.env.API_URL = "https://crm.example.test"; +process.env.APP_URL = "https://crm.example.app"; + +const { oauthClientFields, OAUTH_SCOPES } = await import("../src/oauth-config"); + +describe("oauthClientFields", () => { + const fields = oauthClientFields({ + clientId: "custom-client", + name: "Custom client", + redirectUris: ["https://app.example/callback"], + postLogoutRedirectUris: ["https://app.example/logout"], + skipConsent: false, + }); + + it("identifies the client by its clientId", () => { + expect(fields.clientId).toBe("custom-client"); + }); + + it("grants every declared scope", () => { + expect(fields.scopes).toEqual([...OAUTH_SCOPES]); + }); + + it("demands PKCE for the native flow", () => { + expect(fields.requirePKCE).toBe(true); + expect(fields.tokenEndpointAuthMethod).toBe("none"); + }); + + it("supports only the authorization-code grant", () => { + expect(fields.grantTypes).toEqual(["authorization_code", "refresh_token"]); + expect(fields.responseTypes).toEqual(["code"]); + }); +}); diff --git a/packages/auth/test/oauth-scope.spec.ts b/packages/auth/test/oauth-scope.spec.ts new file mode 100644 index 000000000..fcc7cb8d3 --- /dev/null +++ b/packages/auth/test/oauth-scope.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test"; + +process.env.API_URL = "https://crm.example.test"; +process.env.APP_URL = "https://crm.example.app"; + +const { bearerChallenge, oauthScopeFailure, requiredCrmScope } = await import( + "../src/oauth-scope" +); + +describe("requiredCrmScope", () => { + it("returns the read scope for reads", () => { + expect(requiredCrmScope(false)).toBe("crm.read"); + }); + + it("returns the write scope for writes", () => { + expect(requiredCrmScope(true)).toBe("crm.write"); + }); +}); + +describe("bearerChallenge", () => { + it("names only the realm when there is no error", () => { + expect(bearerChallenge()).toBe('Bearer realm="compcrm"'); + }); + + it("adds the error code when given one", () => { + expect(bearerChallenge("invalid_token")).toBe( + 'Bearer realm="compcrm", error="invalid_token"', + ); + }); + + it("adds the scope alongside the error", () => { + expect(bearerChallenge("insufficient_scope", "crm.write")).toBe( + 'Bearer realm="compcrm", error="insufficient_scope", scope="crm.write"', + ); + }); +}); + +describe("oauthScopeFailure", () => { + it("returns null when the scope is granted", () => { + expect(oauthScopeFailure(new Set(["crm.read"]), "crm.read")).toBeNull(); + }); + + it("returns the challenge and message when the scope is missing", () => { + const failure = oauthScopeFailure(new Set(["crm.read"]), "crm.write"); + + expect(failure).toEqual({ + challenge: + 'Bearer realm="compcrm", error="insufficient_scope", scope="crm.write"', + message: "The token requires crm.write.", + }); + }); +}); diff --git a/packages/auth/test/oauth-startup.spec.ts b/packages/auth/test/oauth-startup.spec.ts new file mode 100644 index 000000000..dce67e197 --- /dev/null +++ b/packages/auth/test/oauth-startup.spec.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test"; +import { oauthProvider } from "@better-auth/oauth-provider"; +import { betterAuth } from "better-auth"; +import { memoryAdapter } from "better-auth/adapters/memory"; + +function startup(errorCode: string, persistent = false) { + const resource = "https://api.example.test"; + const memory = memoryAdapter({ + oauthResource: [], + oauthClient: [], + oauthClientResource: [], + }); + let attempts = 0; + const auth = betterAuth({ + baseURL: "https://auth.example.test", + secret: "oauth-startup-test-secret-with-at-least-32-characters", + database: (options) => { + const adapter = memory(options); + return { + ...adapter, + findOne: async (input) => { + if (input.model === "oauthResource") { + attempts += 1; + if (attempts === 1 || persistent) { + throw Object.assign(new Error("Database unavailable"), { + code: errorCode, + }); + } + } + return adapter.findOne(input); + }, + }; + }, + plugins: [ + oauthProvider({ + loginPage: "/sign-in", + consentPage: "/consent", + disableJwtPlugin: true, + resources: [{ identifier: resource, name: "Test API" }], + resourceSeedMode: "overwrite", + clientRegistrationDefaultResources: [resource], + allowDynamicClientRegistration: true, + allowUnauthenticatedClientRegistration: true, + }), + ], + }); + const register = () => + auth.api.registerOAuthClient({ + body: { + client_name: "Startup regression", + redirect_uris: ["https://client.example.test/callback"], + token_endpoint_auth_method: "none", + }, + }); + return { auth, register, attempts: () => attempts }; +} + +describe("OAuth startup database recovery", () => { + it("serves sessions and retries resource setup after a connection reset", async () => { + const { auth, register, attempts } = startup("ECONNRESET"); + expect(await auth.api.getSession({ headers: new Headers() })).toBeNull(); + expect(await register()).toHaveProperty("client_id"); + expect(attempts()).toBeGreaterThan(1); + }); + + it("keeps resource requests blocked while the database remains unavailable", async () => { + const { auth, register, attempts } = startup("ECONNRESET", true); + await auth.$context; + await expect(register()).rejects.toThrow("Database unavailable"); + await expect(register()).rejects.toThrow("Database unavailable"); + expect(attempts()).toBe(3); + }); + + it("preserves initialization failures for schema errors", async () => { + const { auth } = startup("P2022"); + await expect(auth.$context).rejects.toThrow("Database unavailable"); + }); +}); diff --git a/packages/auth/test/oauth-tenant.integration.spec.ts b/packages/auth/test/oauth-tenant.integration.spec.ts new file mode 100644 index 000000000..adcec662d --- /dev/null +++ b/packages/auth/test/oauth-tenant.integration.spec.ts @@ -0,0 +1,165 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { z } from "zod"; + +const tokenResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), +}); + +const suffix = `oauth-tenant-${crypto.randomUUID()}`; +const userId = `${suffix}-user`; +const organizationId = `${suffix}-organization`; +const sessionToken = `${suffix}-session`; +let cookie = ""; +let verifier = ""; + +const { + auth, + ensureOfficialOAuthClient, + OAUTH, + OAUTH_ORGANIZATION_CLAIM, + SESSION_COOKIE_NAME, +} = await import("../src/index"); + +beforeAll(async () => { + await db.jwks.deleteMany(); + await ensureOfficialOAuthClient(); + await db.user.create({ + data: { + id: userId, + email: `${userId}@example.com`, + name: "OAuth tenant user", + emailVerified: true, + updatedAt: new Date(), + }, + }); + await db.organization.create({ + data: { + id: organizationId, + name: "OAuth tenant organization", + slug: organizationId, + createdAt: new Date(), + }, + }); + await db.member.create({ + data: { + id: `${suffix}-member`, + userId, + organizationId, + role: "owner", + createdAt: new Date(), + }, + }); + await db.session.create({ + data: { + id: sessionToken, + token: sessionToken, + userId, + activeOrganizationId: organizationId, + expiresAt: new Date(Date.now() + 60_000), + updatedAt: new Date(), + }, + }); + cookie = `${SESSION_COOKIE_NAME}=${await signCookieValue(sessionToken)}`; + verifier = `${crypto.randomUUID()}${crypto.randomUUID()}`.replaceAll("-", ""); +}); + +afterAll(async () => { + await db.user.delete({ where: { id: userId } }); + await db.organization.delete({ where: { id: organizationId } }); + await db.jwks.deleteMany(); +}); + +describe("OAuth tenant binding", () => { + it("preserves the selected organization through token refresh", async () => { + const challenge = Buffer.from( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)), + ).toString("base64url"); + const authorizeUrl = new URL(`${OAUTH.issuer}/oauth2/authorize`); + authorizeUrl.search = new URLSearchParams({ + client_id: OAUTH.officialClient.id, + redirect_uri: OAUTH.officialClient.redirectUris[0], + response_type: "code", + scope: "openid profile email offline_access crm.read", + code_challenge: challenge, + code_challenge_method: "S256", + state: suffix, + resource: OAUTH.resource, + }).toString(); + + const authorization = await auth.handler( + new Request(authorizeUrl, { headers: { cookie }, redirect: "manual" }), + ); + expect(authorization.status).toBe(302); + const location = authorization.headers.get("location"); + if (!location) throw new Error("OAuth authorization returned no redirect."); + const code = new URL(location).searchParams.get("code"); + if (!code) throw new Error("OAuth authorization returned no code."); + + const tokens = await tokenRequest({ + grant_type: "authorization_code", + client_id: OAUTH.officialClient.id, + redirect_uri: OAUTH.officialClient.redirectUris[0], + resource: OAUTH.resource, + code, + code_verifier: verifier, + }); + expect( + accessTokenClaims(tokens.access_token)[OAUTH_ORGANIZATION_CLAIM], + ).toBe(organizationId); + + const refreshed = await tokenRequest({ + grant_type: "refresh_token", + client_id: OAUTH.officialClient.id, + refresh_token: tokens.refresh_token, + resource: OAUTH.resource, + }); + expect( + accessTokenClaims(refreshed.access_token)[OAUTH_ORGANIZATION_CLAIM], + ).toBe(organizationId); + }); +}); + +async function tokenRequest( + body: Record, +): Promise<{ access_token: string; refresh_token: string }> { + const response = await auth.handler( + new Request(`${OAUTH.issuer}/oauth2/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(body), + }), + ); + const payload = await response.json(); + if (!response.ok) throw new Error(JSON.stringify(payload)); + return tokenResponseSchema.parse(payload); +} + +function accessTokenClaims(accessToken: string): Record { + const encoded = accessToken.split(".")[1]; + if (!encoded) throw new Error("Access token has no payload."); + return JSON.parse( + Buffer.from(encoded, "base64url").toString("utf8"), + ) as Record; +} + +async function signCookieValue(value: string): Promise { + const secret = process.env.BETTER_AUTH_SECRET; + if (!secret) throw new Error("BETTER_AUTH_SECRET is required."); + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(value), + ); + return encodeURIComponent( + `${value}.${Buffer.from(signature).toString("base64")}`, + ); +} diff --git a/packages/auth/test/organization.integration.spec.ts b/packages/auth/test/organization.integration.spec.ts index 9f5f3a015..618188eda 100644 --- a/packages/auth/test/organization.integration.spec.ts +++ b/packages/auth/test/organization.integration.spec.ts @@ -1,122 +1,137 @@ import { afterAll, beforeEach, describe, expect, it } from "bun:test"; import { db } from "@crm/db"; -import { ensureWorkspaceMembership, WORKSPACE_ID } from "../src/organization"; +import { runInTenant } from "@crm/db/tenant-context"; +import { + activeWorkspaceRoleOf, + resolveActiveOrganization, + workspaceRoleOf, +} from "../src/organization"; const suffix = process.env.TEST_RUN_ID ?? "organization-spec"; const emailOf = (label: string) => `${label}.${suffix}@example.test`; -let firstId: string; -let secondId: string; +let userId: string; +let orgAId: string; +let orgBId: string; -const seedUser = async (label: string, createdAt: Date): Promise => { +const seedUser = async (label: string): Promise => { const user = await db.user.create({ + data: { id: `${suffix}-${label}`, name: label, email: emailOf(label) }, + select: { id: true }, + }); + return user.id; +}; + +const seedOrg = async (label: string): Promise => { + const org = await db.organization.create({ data: { id: `${suffix}-${label}`, name: label, - email: emailOf(label), - createdAt, - updatedAt: createdAt, + slug: `${suffix}-${label}`, + createdAt: new Date(), }, select: { id: true }, }); - - return user.id; + return org.id; }; -const roleOf = async (userId: string): Promise => { - const member = await db.member.findUnique({ - where: { organizationId_userId: { organizationId: WORKSPACE_ID, userId } }, - select: { role: true }, +const addMember = async ( + organizationId: string, + memberUserId: string, + role: string, + createdAt: Date, +) => { + await db.member.create({ + data: { + id: `${suffix}-${organizationId}-${memberUserId}`, + organizationId, + userId: memberUserId, + role, + createdAt, + }, }); - - return member?.role ?? null; }; const clear = async () => { await db.member.deleteMany({ where: { userId: { startsWith: `${suffix}-` } }, }); + await db.organization.deleteMany({ + where: { id: { startsWith: `${suffix}-` } }, + }); await db.user.deleteMany({ where: { email: { endsWith: `.${suffix}@example.test` } }, }); - - const strangers = await db.member.count({ - where: { organizationId: WORKSPACE_ID }, - }); - - if (strangers > 0) { - throw new Error( - `${strangers} member row(s) this spec did not create are in the workspace, and it needs an empty one to test the owner backfill. It will not delete them: that is somebody's access. Point TEST_DATABASE_URL at a database of your own, or find the spec that leaked them.`, - ); - } }; beforeEach(async () => { await clear(); - - firstId = await seedUser("first", new Date("2020-01-01T00:00:00Z")); - secondId = await seedUser("second", new Date("2021-01-01T00:00:00Z")); + userId = await seedUser("user"); + orgAId = await seedOrg("org-a"); + orgBId = await seedOrg("org-b"); }); afterAll(clear); -describe("ensureWorkspaceMembership", () => { - it("creates the one workspace and enrols everyone who already had an account", async () => { - const workspaceId = await ensureWorkspaceMembership(secondId); - - expect(workspaceId).toBe(WORKSPACE_ID); - expect(await roleOf(firstId)).toBe("owner"); - expect(await roleOf(secondId)).toBe("member"); +describe("resolveActiveOrganization", () => { + it("returns null for a user with no membership anywhere", async () => { + expect(await resolveActiveOrganization(userId)).toBeNull(); }); - it("is idempotent, so signing in again neither duplicates nor re-roles", async () => { - await ensureWorkspaceMembership(secondId); - - await db.member.update({ - where: { - organizationId_userId: { - organizationId: WORKSPACE_ID, - userId: secondId, - }, - }, - data: { role: "admin" }, - }); + it("returns the user's sole organization", async () => { + await addMember(orgAId, userId, "member", new Date("2026-01-01T00:00:00Z")); - await ensureWorkspaceMembership(secondId); - await ensureWorkspaceMembership(secondId); + expect(await resolveActiveOrganization(userId)).toBe(orgAId); + }); - const rows = await db.member.findMany({ - where: { organizationId: WORKSPACE_ID, userId: secondId }, - }); + it("returns the earliest-joined organization when the user belongs to several", async () => { + await addMember(orgBId, userId, "member", new Date("2026-02-01T00:00:00Z")); + await addMember(orgAId, userId, "member", new Date("2026-01-01T00:00:00Z")); - expect(rows).toHaveLength(1); - expect(rows[0]?.role).toBe("admin"); + expect(await resolveActiveOrganization(userId)).toBe(orgAId); }); - it("joins someone who signs up later as a member", async () => { - await ensureWorkspaceMembership(secondId); + it("does not create any organization or membership as a side effect", async () => { + await resolveActiveOrganization(userId); - const laterId = await seedUser("later", new Date("2026-01-01T00:00:00Z")); + const memberCount = await db.member.count({ where: { userId } }); + expect(memberCount).toBe(0); + }); +}); - await ensureWorkspaceMembership(laterId); +describe("workspaceRoleOf", () => { + it("returns the role for the given user in the given organization", async () => { + await addMember(orgAId, userId, "admin", new Date()); - expect(await roleOf(laterId)).toBe("member"); + expect(await workspaceRoleOf(userId, orgAId)).toBe("admin"); }); - it("leaves the owner alone when a later arrival signs in", async () => { - await ensureWorkspaceMembership(secondId); + it("returns null when the user is not a member of that organization", async () => { + await addMember(orgAId, userId, "admin", new Date()); - const laterId = await seedUser("later", new Date("2026-01-01T00:00:00Z")); + expect(await workspaceRoleOf(userId, orgBId)).toBeNull(); + }); - await ensureWorkspaceMembership(laterId); + it("keeps roles isolated across a user's two organizations", async () => { + await addMember(orgAId, userId, "owner", new Date()); + await addMember(orgBId, userId, "member", new Date()); - expect(await roleOf(firstId)).toBe("owner"); + expect(await workspaceRoleOf(userId, orgAId)).toBe("owner"); + expect(await workspaceRoleOf(userId, orgBId)).toBe("member"); + }); +}); - const owners = await db.member.count({ - where: { organizationId: WORKSPACE_ID, role: "owner" }, - }); +describe("activeWorkspaceRoleOf", () => { + it("reads the role from the active organization", async () => { + await addMember(orgAId, userId, "admin", new Date()); + await addMember(orgBId, userId, "member", new Date()); - expect(owners).toBe(1); + expect(await runInTenant(orgAId, () => activeWorkspaceRoleOf(userId))).toBe( + "admin", + ); + expect(await runInTenant(orgBId, () => activeWorkspaceRoleOf(userId))).toBe( + "member", + ); }); }); diff --git a/packages/auth/test/session-active-organization.integration.spec.ts b/packages/auth/test/session-active-organization.integration.spec.ts new file mode 100644 index 000000000..d04489f9a --- /dev/null +++ b/packages/auth/test/session-active-organization.integration.spec.ts @@ -0,0 +1,67 @@ +import { afterAll, beforeEach, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { auth } from "../src/auth"; + +const suffix = process.env.TEST_RUN_ID ?? "session-active-org-spec"; + +let userId: string; +let orgId: string; + +const clear = async () => { + await db.session.deleteMany({ + where: { userId: { startsWith: `${suffix}-` } }, + }); + await db.member.deleteMany({ + where: { userId: { startsWith: `${suffix}-` } }, + }); + await db.organization.deleteMany({ + where: { id: { startsWith: `${suffix}-` } }, + }); + await db.user.deleteMany({ where: { id: { startsWith: `${suffix}-` } } }); +}; + +beforeEach(async () => { + await clear(); + + const user = await db.user.create({ + data: { + id: `${suffix}-user`, + name: "Test", + email: `${suffix}@example.test`, + }, + select: { id: true }, + }); + userId = user.id; + + const org = await db.organization.create({ + data: { + id: `${suffix}-org`, + name: "Org", + slug: `${suffix}-org`, + createdAt: new Date(), + }, + select: { id: true }, + }); + orgId = org.id; + + await db.member.create({ + data: { + id: `${suffix}-member`, + organizationId: orgId, + userId, + role: "owner", + createdAt: new Date(), + }, + }); +}); + +afterAll(clear); + +describe("session create hook", () => { + it("sets activeOrganizationId to the user's membership via the internal adapter", async () => { + const context = await auth.$context; + const created = await context.internalAdapter.createSession(userId); + + expect(created.activeOrganizationId).toBe(orgId); + }); +}); diff --git a/packages/auth/test/slack-connect.integration.spec.ts b/packages/auth/test/slack-connect.integration.spec.ts index c5b7e9207..98f6a928a 100644 --- a/packages/auth/test/slack-connect.integration.spec.ts +++ b/packages/auth/test/slack-connect.integration.spec.ts @@ -7,59 +7,55 @@ import { it, } from "bun:test"; import { db } from "@crm/db"; -import { workspaceSlug } from "@crm/db/workspace"; -import { type BetterAuthPlugin, betterAuth } from "better-auth"; +import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; -import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api"; +import { APIError, createAuthMiddleware } from "better-auth/api"; import { applySetCookies } from "better-auth/cookies"; import { genericOAuth } from "better-auth/plugins/generic-oauth"; +import { organization } from "better-auth/plugins/organization"; import * as z from "zod"; -import { - DEFAULT_WORKSPACE_NAME, - WORKSPACE_ID, - type WorkspaceRole, -} from "../src/organization"; +import type { WorkspaceRole } from "../src/organization"; import { GOOGLE_PROVIDER_ID, SLACK_PROVIDER_ID } from "../src/scopes"; import { slackConnectGuard } from "../src/slack-connect"; const suffix = process.env.TEST_RUN_ID ?? "slack-connect-spec"; +const ORGANIZATION_ID = `${suffix}-organization`; +const CALLBACK_INSTALLER = `slack-connect-${suffix}-installer`; const EMAIL_SUFFIX = `.slack-connect.${suffix}@example.test`; const SESSION_MS = 7 * 24 * 60 * 60 * 1000; const BASE_URL = "http://localhost:3001"; const JSON_HEADERS = { "content-type": "application/json" }; -const reached = { reached: true }; - -const probe = { - id: "slack-connect-probe", - endpoints: { - link: createAuthEndpoint("/oauth2/link", { method: "POST" }, async (ctx) => - ctx.json(reached), - ), - signIn: createAuthEndpoint( - "/sign-in/oauth2", - { method: "POST" }, - async (ctx) => ctx.json(reached), - ), - callback: createAuthEndpoint( - "/oauth2/callback/:providerId", - { method: "GET" }, - async (ctx) => ctx.json(reached), - ), +const provider = (providerId: string) => ({ + providerId, + accountIssuer: `https://${providerId}.example.test`, + authorizationUrl: `https://${providerId}.example.test/authorize`, + tokenUrl: `https://${providerId}.example.test/token`, + userInfoUrl: `https://${providerId}.example.test/userinfo`, + clientId: `${providerId}-client`, + clientSecret: `${providerId}-secret`, + getToken: async () => { + throw new APIError("BAD_REQUEST", { message: "Token exchange reached." }); }, -} satisfies BetterAuthPlugin; +}); const guarded = betterAuth({ baseURL: BASE_URL, secret: "slack-connect-spec-secret", database: prismaAdapter(db, { provider: "postgresql" }), emailAndPassword: { enabled: false }, + account: { skipStateCookieCheck: true }, hooks: { before: slackConnectGuard }, - plugins: [probe], + plugins: [ + genericOAuth({ + config: [provider(SLACK_PROVIDER_ID), provider(GOOGLE_PROVIDER_ID)], + }), + organization({ allowUserToCreateOrganization: false }), + ], }); -const arrival = z.object({ reached: z.literal(true) }); +const authorization = z.object({ url: z.string().url() }); const refusal = z.object({ message: z.string() }); type Snapshot = { @@ -76,7 +72,10 @@ let snapshot: Snapshot; const idOf = (label: string) => `slack-connect-${suffix}-${label}`; -const sessionCookie = async (userId: string): Promise => { +const sessionCookie = async ( + userId: string, + activeOrganizationId: string | null, +): Promise => { const context = await guarded.$context; const token = idOf(`${userId}-token`); @@ -88,6 +87,7 @@ const sessionCookie = async (userId: string): Promise => { expiresAt: new Date(Date.now() + SESSION_MS), createdAt: new Date(), updatedAt: new Date(), + activeOrganizationId, }, }); @@ -123,7 +123,7 @@ const seat = async ( await db.member.create({ data: { id: idOf(`${label}-member`), - organizationId: WORKSPACE_ID, + organizationId: ORGANIZATION_ID, userId: user.id, role, createdAt: now, @@ -131,7 +131,7 @@ const seat = async ( }); } - return sessionCookie(user.id); + return sessionCookie(user.id, role ? ORGANIZATION_ID : null); }; const startConnect = ( @@ -143,45 +143,83 @@ const startConnect = ( new Request(`${BASE_URL}/api/auth${path}`, { method: "POST", headers: cookie ? { ...JSON_HEADERS, cookie } : JSON_HEADERS, - body: JSON.stringify({ providerId, callbackURL: "/" }), + body: JSON.stringify({ provider: providerId, callbackURL: "/" }), }), ); -const linkSlack = (cookie?: string) => startConnect("/oauth2/link", cookie); +const linkSlack = (cookie?: string) => startConnect("/link-social", cookie); -const completeConnect = ( +const completeCallback = ( + state: string, cookie?: string, providerId: string = SLACK_PROVIDER_ID, ) => guarded.handler( new Request( - `${BASE_URL}/api/auth/oauth2/callback/${providerId}?code=test-code&state=test-state`, + `${BASE_URL}/api/auth/callback/${providerId}?code=test-code&state=${state}`, { headers: cookie ? { cookie } : undefined }, ), ); +const linkTransaction = async ( + cookie: string, + providerId: string = SLACK_PROVIDER_ID, +): Promise<{ state: string; cookie: string }> => { + const state = `slack-connect-state-${crypto.randomUUID()}`; + await db.verification.create({ + data: { + id: state, + identifier: state, + value: JSON.stringify({ + callbackURL: "/", + codeVerifier: "slack-connect-code-verifier", + expiresAt: Date.now() + 60_000, + link: { email: `${providerId}@example.test`, userId: providerId }, + }), + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + return { state, cookie }; +}; + +const completeLink = async (startCookie: string, keepCallbackCookie = true) => { + const transaction = await linkTransaction(startCookie); + return completeCallback( + transaction.state, + keepCallbackCookie ? transaction.cookie : undefined, + ); +}; + const messageOf = async (response: Response): Promise => refusal.parse(await response.json()).message; const arrived = async (response: Response): Promise => - arrival.safeParse(await response.json()).success; + authorization.safeParse(await response.json()).success; const clear = async () => { - await db.member.deleteMany({ where: { organizationId: WORKSPACE_ID } }); - await db.organization.deleteMany({ where: { id: WORKSPACE_ID } }); + await db.verification.deleteMany({ + where: { identifier: { startsWith: "slack-connect-state-" } }, + }); + await db.slackInstallation.deleteMany({ + where: { installerId: CALLBACK_INSTALLER }, + }); + await db.member.deleteMany({ where: { organizationId: ORGANIZATION_ID } }); + await db.organization.deleteMany({ where: { id: ORGANIZATION_ID } }); await db.user.deleteMany({ where: { email: { endsWith: EMAIL_SUFFIX } } }); }; beforeAll(async () => { const organization = await db.organization.findUnique({ - where: { id: WORKSPACE_ID }, + where: { id: ORGANIZATION_ID }, select: { name: true, slug: true, website: true, metadata: true }, }); snapshot = { organization, members: await db.member.findMany({ - where: { organizationId: WORKSPACE_ID }, + where: { organizationId: ORGANIZATION_ID }, select: { id: true, userId: true, role: true, createdAt: true }, }), }; @@ -192,9 +230,9 @@ beforeEach(async () => { await db.organization.create({ data: { - id: WORKSPACE_ID, - name: DEFAULT_WORKSPACE_NAME, - slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), + id: ORGANIZATION_ID, + name: "CRM", + slug: ORGANIZATION_ID, createdAt: new Date(), }, }); @@ -206,7 +244,7 @@ afterAll(async () => { if (snapshot.organization) { await db.organization.create({ data: { - id: WORKSPACE_ID, + id: ORGANIZATION_ID, createdAt: new Date(), ...snapshot.organization, }, @@ -215,62 +253,81 @@ afterAll(async () => { await db.member.createMany({ data: snapshot.members.map((member) => ({ ...member, - organizationId: WORKSPACE_ID, + organizationId: ORGANIZATION_ID, })), }); } }); -describe("the Slack callback that writes the connection", () => { - it("turns away a browser with no session", async () => { - const response = await completeConnect(); +describe("Slack linking callback authorization", () => { + it("turns away a linking browser after its session ends", async () => { + const cookie = await seat("lead", "admin"); + const response = await completeLink(cookie, false); expect(response.status).toBe(401); expect(await messageOf(response)).toContain("Sign in to the CRM"); }); - it("turns away a member", async () => { + it("turns away an admin who became a member", async () => { + const cookie = await seat("lead", "admin"); + const transaction = await linkTransaction(cookie); + await db.member.update({ + where: { id: idOf("lead-member") }, + data: { role: "member" }, + }); await seat("owner", "owner"); - const response = await completeConnect(await seat("rep", "member")); + const response = await completeCallback( + transaction.state, + transaction.cookie, + ); expect(response.status).toBe(403); expect(await messageOf(response)).toContain("Only an owner or an admin"); }); - it("turns away someone signed in who is not in this workspace", async () => { - await seat("owner", "owner"); - const response = await completeConnect(await seat("stranger", null)); + it("turns away an admin removed from the workspace", async () => { + const cookie = await seat("lead", "admin"); + const transaction = await linkTransaction(cookie); + await db.member.delete({ where: { id: idOf("lead-member") } }); + const response = await completeCallback( + transaction.state, + transaction.cookie, + ); expect(response.status).toBe(403); - expect(await messageOf(response)).toContain("member of this workspace"); + expect(await messageOf(response)).toContain( + "Only a member of this workspace", + ); }); - it("lets an admin finish", async () => { - const response = await completeConnect(await seat("lead", "admin")); + it("lets an admin reach token exchange", async () => { + const cookie = await seat("lead", "admin"); + const response = await completeLink(cookie); - expect(response.status).toBe(200); - expect(await arrived(response)).toBe(true); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toContain("error=invalid_code"); }); - it("lets an owner finish", async () => { - const response = await completeConnect(await seat("founder", "owner")); + it("lets an owner reach token exchange", async () => { + const cookie = await seat("founder", "owner"); + const response = await completeLink(cookie); - expect(response.status).toBe(200); - expect(await arrived(response)).toBe(true); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toContain("error=invalid_code"); }); - it("lets a member finish when the workspace has no owner and no admin", async () => { + it("lets a member reach token exchange without a workspace manager", async () => { const cookie = await seat("rep", "member"); await seat("other", "member"); - const response = await completeConnect(cookie); + const response = await completeLink(cookie); - expect(response.status).toBe(200); - expect(await arrived(response)).toBe(true); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toContain("error=invalid_code"); }); }); -describe("the two paths that start a Slack connection", () => { +describe("Slack connection starts", () => { it("turns away a member who asks to link Slack", async () => { await seat("owner", "owner"); const response = await linkSlack(await seat("rep", "member")); @@ -278,14 +335,12 @@ describe("the two paths that start a Slack connection", () => { expect(response.status).toBe(403); }); - it("turns away a member who asks to sign in with Slack", async () => { + it("lets a browser with no session ask to sign in with Slack", async () => { await seat("owner", "owner"); - const response = await startConnect( - "/sign-in/oauth2", - await seat("rep", "member"), - ); + const response = await startConnect("/sign-in/social"); - expect(response.status).toBe(403); + expect(response.status).toBe(200); + expect(await arrived(response)).toBe(true); }); it("turns away a browser with no session", async () => { @@ -307,7 +362,7 @@ describe("every provider that is not Slack", () => { it("lets a member link Google", async () => { await seat("owner", "owner"); const response = await startConnect( - "/oauth2/link", + "/link-social", await seat("rep", "member"), GOOGLE_PROVIDER_ID, ); @@ -317,23 +372,34 @@ describe("every provider that is not Slack", () => { }); it("lets the Google callback through with no session at all", async () => { - const response = await completeConnect(undefined, GOOGLE_PROVIDER_ID); + const response = await completeCallback( + "test-state", + undefined, + GOOGLE_PROVIDER_ID, + ); - expect(response.status).toBe(200); - expect(await arrived(response)).toBe(true); + expect(response.status).toBe(302); }); -}); -describe("the paths the guard has to know about", () => { - it("is every path the generic OAuth plugin mounts", () => { - const paths = Object.values(genericOAuth({ config: [] }).endpoints) - .map((endpoint) => endpoint.path) - .sort(); - - expect(paths).toEqual([ - "/oauth2/callback/:providerId", - "/oauth2/link", - "/sign-in/oauth2", - ]); + it("lets a Slack sign-in callback through with no session", async () => { + const state = `slack-connect-state-${crypto.randomUUID()}`; + await db.verification.create({ + data: { + id: state, + identifier: state, + value: JSON.stringify({ + callbackURL: "/", + codeVerifier: "slack-sign-in-code-verifier", + expiresAt: Date.now() + 60_000, + }), + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + const response = await completeCallback(state); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toContain("error=invalid_code"); }); }); diff --git a/packages/auth/test/slack-grant.integration.spec.ts b/packages/auth/test/slack-grant.integration.spec.ts new file mode 100644 index 000000000..9fba9bdb9 --- /dev/null +++ b/packages/auth/test/slack-grant.integration.spec.ts @@ -0,0 +1,360 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db, type Prisma } from "@crm/db"; +import { runInTenant, TenantContextError } from "@crm/db/tenant-context"; +import { scopedTransaction } from "@crm/db/tenant-scope"; +import type { OauthAccess } from "@crm/validation"; +import { SLACK_PROVIDER_ID } from "../src/scopes"; +import { SLACK_CONNECTION } from "../src/slack-config"; +import { + rememberSlackInstall, + replaceSlackConnection, +} from "../src/slack-grant"; + +const suffix = + process.env.TEST_RUN_ID ?? + `slack-grant-${Date.now()}-${Math.random().toString(16).slice(2)}`; +const ORGANIZATION_A = `slack-grant-${suffix}-organization-a`; +const ORGANIZATION_B = `slack-grant-${suffix}-organization-b`; +const INSTALLER_ID = `slack-grant-${suffix}-installer`; +const EXTERNAL_INSTALLER_ID = `${INSTALLER_ID}-other-org`; +const USER_ID = `slack-grant-${suffix}-user`; +const ACCOUNT_ID = `slack-grant-${suffix}-account`; +const STALE_TEAM_ID_B = `${suffix}-stale-org-b`; + +const grant: OauthAccess = { + ok: true, + access_token: "xoxb-test-token", + scope: "chat:write,channels:read", + team: { id: `slack-grant-${suffix}-team`, name: "Test Team" }, + authed_user: { + id: INSTALLER_ID, + access_token: "xoxp-test-token", + scope: "channels:read", + }, +}; + +async function inTenant( + organizationId: string, + fn: (tx: Prisma.TransactionClient) => Promise, +): Promise { + return runInTenant(organizationId, () => scopedTransaction(fn)); +} + +async function clearSlackRows(): Promise { + for (const organizationId of [ORGANIZATION_A, ORGANIZATION_B]) { + await inTenant(organizationId, async (tx) => { + await tx.slackWorkspaceGrant.deleteMany({ + where: { + teamId: { + in: [ + grant.team.id, + `${grant.team.id}-stale-a`, + `${grant.team.id}-stale-b`, + STALE_TEAM_ID_B, + ], + }, + }, + }); + await tx.slackInstallation.deleteMany({ + where: { + installerId: { + in: [ + INSTALLER_ID, + EXTERNAL_INSTALLER_ID, + `${INSTALLER_ID}-stale-a`, + ], + }, + }, + }); + }); + } +} + +beforeAll(async () => { + await db.organization.createMany({ + data: [ + { + id: ORGANIZATION_A, + name: "Organization A", + slug: ORGANIZATION_A, + createdAt: new Date(), + }, + { + id: ORGANIZATION_B, + name: "Organization B", + slug: ORGANIZATION_B, + createdAt: new Date(), + }, + ], + skipDuplicates: true, + }); + + await db.user.upsert({ + where: { id: USER_ID }, + update: {}, + create: { + id: USER_ID, + name: "Slack grant owner", + email: `${USER_ID}@example.test`, + }, + }); + + await db.account.deleteMany({ where: { id: ACCOUNT_ID } }); + await clearSlackRows(); +}); + +afterAll(async () => { + await db.account.deleteMany({ where: { id: ACCOUNT_ID } }); + await clearSlackRows(); + await db.user.deleteMany({ where: { id: USER_ID } }); + await db.organization.deleteMany({ + where: { id: { in: [ORGANIZATION_A, ORGANIZATION_B] } }, + }); +}); + +describe("rememberSlackInstall", () => { + it("requires tenant context", async () => { + await expect(rememberSlackInstall(grant)).rejects.toThrow( + TenantContextError, + ); + }); + + it("stores the active organization", async () => { + await runInTenant(ORGANIZATION_A, () => rememberSlackInstall(grant)); + + const installation = await inTenant(ORGANIZATION_A, (tx) => + tx.slackInstallation.findUnique({ + where: { + organizationId_installerId: { + organizationId: ORGANIZATION_A, + installerId: INSTALLER_ID, + }, + }, + select: { organizationId: true, botToken: true, botScopes: true }, + }), + ); + + expect(installation).toMatchObject({ + organizationId: ORGANIZATION_A, + botToken: "xoxb-test-token", + botScopes: "chat:write,channels:read", + }); + }); + + it("keeps each organization's installation separate", async () => { + await runInTenant(ORGANIZATION_A, () => rememberSlackInstall(grant)); + await runInTenant(ORGANIZATION_B, () => rememberSlackInstall(grant)); + + const [installationA, installationB] = await Promise.all([ + inTenant(ORGANIZATION_A, (tx) => + tx.slackInstallation.findUnique({ + where: { + organizationId_installerId: { + organizationId: ORGANIZATION_A, + installerId: INSTALLER_ID, + }, + }, + }), + ), + inTenant(ORGANIZATION_B, (tx) => + tx.slackInstallation.findUnique({ + where: { + organizationId_installerId: { + organizationId: ORGANIZATION_B, + installerId: INSTALLER_ID, + }, + }, + }), + ), + ]); + + expect(installationA?.organizationId).toBe(ORGANIZATION_A); + expect(installationB?.organizationId).toBe(ORGANIZATION_B); + }); + + it("does not clear stale installations outside the active tenant", async () => { + await inTenant(ORGANIZATION_A, (tx) => + tx.slackInstallation.create({ + data: { + installerId: `${INSTALLER_ID}-stale-a`, + organizationId: ORGANIZATION_A, + teamId: `${suffix}-stale-team-a`, + teamName: "Old Team A", + userToken: null, + userScopes: "channels:read", + createdAt: new Date( + Date.now() - SLACK_CONNECTION.install.staleMs - 60_000, + ), + }, + }), + ); + + await inTenant(ORGANIZATION_B, (tx) => + tx.slackInstallation.create({ + data: { + installerId: EXTERNAL_INSTALLER_ID, + organizationId: ORGANIZATION_B, + teamId: STALE_TEAM_ID_B, + teamName: "Old Team B", + userToken: null, + userScopes: "channels:read", + createdAt: new Date( + Date.now() - SLACK_CONNECTION.install.staleMs - 60_000, + ), + }, + }), + ); + + await runInTenant(ORGANIZATION_A, () => rememberSlackInstall(grant)); + + const staleA = await inTenant(ORGANIZATION_A, (tx) => + tx.slackInstallation.findUnique({ + where: { + organizationId_installerId: { + organizationId: ORGANIZATION_A, + installerId: `${INSTALLER_ID}-stale-a`, + }, + }, + }), + ); + const staleB = await inTenant(ORGANIZATION_B, (tx) => + tx.slackInstallation.findUnique({ + where: { + organizationId_installerId: { + organizationId: ORGANIZATION_B, + installerId: EXTERNAL_INSTALLER_ID, + }, + }, + }), + ); + + expect(staleA).toBeNull(); + expect(staleB?.organizationId).toBe(ORGANIZATION_B); + }); + + it("replaces only the active organization's workspace grant", async () => { + await runInTenant(ORGANIZATION_A, () => rememberSlackInstall(grant)); + + await db.account.create({ + data: { + id: ACCOUNT_ID, + accountId: INSTALLER_ID, + issuer: "local:oauth:slack", + providerId: SLACK_PROVIDER_ID, + userId: USER_ID, + }, + }); + + await inTenant(ORGANIZATION_A, (tx) => + tx.slackWorkspaceGrant.create({ + data: { + id: `slack-grant-${suffix}-stale-a`, + organizationId: ORGANIZATION_A, + teamId: `${grant.team.id}-stale-a`, + userToken: "stale-token-a", + userScopes: "channels:read", + }, + }), + ); + + await inTenant(ORGANIZATION_B, (tx) => + tx.slackWorkspaceGrant.create({ + data: { + id: `slack-grant-${suffix}-stale-b`, + organizationId: ORGANIZATION_B, + teamId: `${grant.team.id}-stale-b`, + userToken: "stale-token-b", + userScopes: "channels:read", + }, + }), + ); + + await runInTenant(ORGANIZATION_A, () => + replaceSlackConnection({ accountId: INSTALLER_ID }), + ); + + const replaced = await inTenant(ORGANIZATION_A, (tx) => + tx.slackWorkspaceGrant.findUnique({ + where: { + organizationId_teamId: { + organizationId: ORGANIZATION_A, + teamId: grant.team.id, + }, + }, + }), + ); + const staleA = await inTenant(ORGANIZATION_A, (tx) => + tx.slackWorkspaceGrant.findUnique({ + where: { + organizationId_teamId: { + organizationId: ORGANIZATION_A, + teamId: `${grant.team.id}-stale-a`, + }, + }, + }), + ); + const staleB = await inTenant(ORGANIZATION_B, (tx) => + tx.slackWorkspaceGrant.findUnique({ + where: { + organizationId_teamId: { + organizationId: ORGANIZATION_B, + teamId: `${grant.team.id}-stale-b`, + }, + }, + }), + ); + + expect(replaced?.organizationId).toBe(ORGANIZATION_A); + expect(replaced?.botToken).toBe("xoxb-test-token"); + expect(replaced?.botScopes).toBe("chat:write,channels:read"); + expect(replaced?.userToken).toBe("xoxp-test-token"); + expect(staleA).toBeNull(); + expect(staleB?.organizationId).toBe(ORGANIZATION_B); + expect( + await inTenant(ORGANIZATION_A, (tx) => + tx.slackInstallation.findUnique({ + where: { + organizationId_installerId: { + organizationId: ORGANIZATION_A, + installerId: INSTALLER_ID, + }, + }, + }), + ), + ).toBeNull(); + + await runInTenant(ORGANIZATION_B, () => rememberSlackInstall(grant)); + await runInTenant(ORGANIZATION_B, () => + replaceSlackConnection({ accountId: INSTALLER_ID }), + ); + + const [grantA, grantB] = await Promise.all([ + inTenant(ORGANIZATION_A, (tx) => + tx.slackWorkspaceGrant.findUnique({ + where: { + organizationId_teamId: { + organizationId: ORGANIZATION_A, + teamId: grant.team.id, + }, + }, + }), + ), + inTenant(ORGANIZATION_B, (tx) => + tx.slackWorkspaceGrant.findUnique({ + where: { + organizationId_teamId: { + organizationId: ORGANIZATION_B, + teamId: grant.team.id, + }, + }, + }), + ), + ]); + + expect(grantA?.organizationId).toBe(ORGANIZATION_A); + expect(grantB?.organizationId).toBe(ORGANIZATION_B); + expect( + await db.account.findUnique({ where: { id: ACCOUNT_ID } }), + ).not.toBeNull(); + }); +}); diff --git a/packages/auth/test/sso-tenant-context.integration.spec.ts b/packages/auth/test/sso-tenant-context.integration.spec.ts new file mode 100644 index 000000000..052d56a07 --- /dev/null +++ b/packages/auth/test/sso-tenant-context.integration.spec.ts @@ -0,0 +1,178 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { runInTenant, tryCurrentOrganizationId } from "@crm/db/tenant-context"; +import { scopedDb } from "@crm/db/tenant-scope"; +import { auth } from "../src/auth"; +import { + resolveSsoRequestOrganizationId, + runSsoRequestInTenant, +} from "../src/sso-tenant-context"; + +const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; +const organizationA = `sso-request-org-a-${suffix}`; +const organizationB = `sso-request-org-b-${suffix}`; +const providerA = `sso-request-provider-a-${suffix}`; +const providerB = `sso-request-provider-b-${suffix}`; + +beforeAll(async () => { + await db.organization.createMany({ + data: [ + { + id: organizationA, + name: `SSO Request A ${suffix}`, + slug: organizationA, + createdAt: new Date(), + }, + { + id: organizationB, + name: `SSO Request B ${suffix}`, + slug: organizationB, + createdAt: new Date(), + }, + ], + }); + await runInTenant(organizationA, () => + scopedDb.ssoProvider.create({ + data: { + id: `sso-request-row-a-${suffix}`, + providerId: providerA, + issuer: "https://a.example.com", + domain: "a.example.com, parent.example.com", + }, + }), + ); + await runInTenant(organizationB, () => + scopedDb.ssoProvider.create({ + data: { + id: `sso-request-row-b-${suffix}`, + providerId: providerB, + issuer: "https://b.example.com", + domain: "b.example.com", + }, + }), + ); +}); + +afterAll(async () => { + await db.organization.deleteMany({ + where: { id: { in: [organizationA, organizationB] } }, + }); +}); + +describe("SSO request tenant routing", () => { + it("scopes the Better Auth SSO adapter", async () => { + const adapter = (await auth.$context).adapter; + + await expect( + Promise.resolve( + adapter.findOne({ + model: "ssoProvider", + where: [{ field: "providerId", value: providerA }], + }), + ), + ).rejects.toThrow("No active tenant context"); + + const provider = await runSsoRequestInTenant( + { + url: "/api/auth/sign-in/sso", + body: { providerId: providerA }, + }, + () => + adapter.findOne({ + model: "ssoProvider", + where: [{ field: "providerId", value: providerA }], + }), + ); + + expect(provider?.organizationId).toBe(organizationA); + }); + + it("routes provider sign-in and callback requests", async () => { + expect( + await resolveSsoRequestOrganizationId({ + originalUrl: "/api/auth/sign-in/sso", + body: { providerId: providerA }, + }), + ).toBe(organizationA); + + expect( + await resolveSsoRequestOrganizationId({ + originalUrl: `/api/auth/sso/callback/${providerB}?code=ok`, + }), + ).toBe(organizationB); + }); + + it("routes domain, email, and organization sign-in requests", async () => { + expect( + await resolveSsoRequestOrganizationId({ + url: "/api/auth/sign-in/sso", + body: { domain: "child.parent.example.com" }, + }), + ).toBe(organizationA); + + expect( + await resolveSsoRequestOrganizationId({ + url: "/api/auth/sign-in/sso", + body: { email: "person@b.example.com" }, + }), + ).toBe(organizationB); + + expect( + await resolveSsoRequestOrganizationId({ + url: "/api/auth/sign-in/sso", + body: { organizationSlug: organizationA }, + }), + ).toBe(organizationA); + }); + + it("routes provider management requests", async () => { + expect( + await resolveSsoRequestOrganizationId({ + url: `/api/auth/sso/get-provider?providerId=${providerA}`, + }), + ).toBe(organizationA); + + expect( + await resolveSsoRequestOrganizationId({ + url: "/api/auth/sso/register", + body: { organizationId: organizationB }, + }), + ).toBe(organizationB); + }); + + it("does not create context for unrelated or unresolved requests", async () => { + await runSsoRequestInTenant({ url: "/api/auth/session" }, async () => { + expect(tryCurrentOrganizationId()).toBeUndefined(); + }); + + await runSsoRequestInTenant( + { + url: "/api/auth/sign-in/sso", + body: { providerId: `missing-${suffix}` }, + }, + async () => { + expect(tryCurrentOrganizationId()).toBeUndefined(); + }, + ); + }); + + it("keeps concurrent tenant contexts separate", async () => { + const seen = await Promise.all( + [providerA, providerB].map((providerId) => + runSsoRequestInTenant( + { + url: "/api/auth/sign-in/sso", + body: { providerId }, + }, + async () => { + await Promise.resolve(); + return tryCurrentOrganizationId(); + }, + ), + ), + ); + + expect(seen).toEqual([organizationA, organizationB]); + expect(tryCurrentOrganizationId()).toBeUndefined(); + }); +}); diff --git a/packages/auth/test/sso.spec.ts b/packages/auth/test/sso.spec.ts index a69d6e115..ddfa3fa10 100644 --- a/packages/auth/test/sso.spec.ts +++ b/packages/auth/test/sso.spec.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from "bun:test"; -process.env.API_URL = "https://crm.example.test"; - const { canConfigureSso, ssoCallbackBase, ssoCallbackURL, ssoProviderName } = await import("../src/sso"); +const { apiUrl } = await import("../src/env"); describe("canConfigureSso", () => { it("is the same answer as renaming the workspace", () => { @@ -16,15 +15,11 @@ describe("canConfigureSso", () => { describe("ssoCallbackURL", () => { it("is the API origin plus the path better-auth mounts the callback on", () => { - expect(ssoCallbackURL("okta")).toBe( - "https://crm.example.test/api/auth/sso/callback/okta", - ); + expect(ssoCallbackURL("okta")).toBe(`${apiUrl}/api/auth/sso/callback/okta`); }); it("hangs off the base the settings page shows", () => { - expect(ssoCallbackBase()).toBe( - "https://crm.example.test/api/auth/sso/callback", - ); + expect(ssoCallbackBase()).toBe(`${apiUrl}/api/auth/sso/callback`); }); }); diff --git a/packages/auth/test/workspace.spec.ts b/packages/auth/test/workspace.spec.ts new file mode 100644 index 000000000..d047c111e --- /dev/null +++ b/packages/auth/test/workspace.spec.ts @@ -0,0 +1,29 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { googleHostedDomain } from "../src/workspace"; + +const originalAllowList = process.env.ALLOWED_SIGN_IN; + +afterAll(() => { + if (originalAllowList === undefined) { + delete process.env.ALLOWED_SIGN_IN; + return; + } + process.env.ALLOWED_SIGN_IN = originalAllowList; +}); + +describe("googleHostedDomain", () => { + it("uses the only allowed domain", () => { + process.env.ALLOWED_SIGN_IN = "majesticlabs.dev"; + expect(googleHostedDomain()).toBe("majesticlabs.dev"); + }); + + it("does not restrict Google when an address is allowed", () => { + process.env.ALLOWED_SIGN_IN = "david@paluy.org, majesticlabs.dev"; + expect(googleHostedDomain()).toBeUndefined(); + }); + + it("does not restrict Google when multiple domains are allowed", () => { + process.env.ALLOWED_SIGN_IN = "majesticlabs.dev, altertx.com"; + expect(googleHostedDomain()).toBeUndefined(); + }); +}); diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json index 3c44722d2..954d8582e 100644 --- a/packages/auth/tsconfig.json +++ b/packages/auth/tsconfig.json @@ -7,6 +7,6 @@ "declaration": false, "declarationMap": false }, - "include": ["src/**/*.ts", "src/**/*.tsx"], + "include": ["scripts/**/*.ts", "src/**/*.ts", "src/**/*.tsx"], "exclude": ["node_modules"] } diff --git a/packages/auth/turbo.json b/packages/auth/turbo.json index 7160b71b9..db3cf95d4 100644 --- a/packages/auth/turbo.json +++ b/packages/auth/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://turborepo.dev/schema.json", + "$schema": "https://v2-10-12.turborepo.dev/schema.json", "extends": ["//"], "tasks": { "auth:generate": { diff --git a/packages/db/README.md b/packages/db/README.md index e4be5ff76..1aefb0bcf 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -65,7 +65,7 @@ generated model map. needs no `transpilePackages` entry. Non-bundler consumers need a TypeScript runtime — the NestJS API runs on Bun for exactly this reason. - **Auth models are generated.** `User`, `Session`, `Account`, `Verification` - and `RateLimit` come from `@better-auth/cli`. Do not hand-edit them — change + and `RateLimit` come from `auth`. Do not hand-edit them — change the Better Auth config in `@crm/auth` and re-run `bun run auth:generate`. The generator is additive: it adds models and fields a plugin needs but never removes the ones a dropped plugin left behind, so removing a plugin means diff --git a/packages/db/docker/init-runtime-role.sql b/packages/db/docker/init-runtime-role.sql new file mode 100644 index 000000000..c9c0f14e9 --- /dev/null +++ b/packages/db/docker/init-runtime-role.sql @@ -0,0 +1,5 @@ +CREATE ROLE crm + WITH LOGIN PASSWORD 'crm' + NOSUPERUSER CREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS; + +ALTER DATABASE crm OWNER TO crm; diff --git a/packages/db/package.json b/packages/db/package.json index 4935913b1..690b6c408 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -20,11 +20,17 @@ "./images": "./src/images.ts", "./idempotency": "./src/idempotency.ts", "./json": "./src/json.ts", + "./pool": "./src/pool.ts", "./safe-fetch": "./src/safe-fetch.ts", "./settings": "./src/settings.ts", "./slack-inventory": "./src/slack-inventory.ts", "./tracking": "./src/tracking.ts", - "./workspace": "./src/workspace.ts" + "./tenant-context": "./src/tenant-context.ts", + "./tenant-scope": "./src/tenant-scope.ts", + "./tenants": "./src/tenants.ts", + "./test-support": "./src/test-support.ts", + "./workspace": "./src/workspace.ts", + "./workspace-slug": "./src/workspace-slug.ts" }, "scripts": { "build": "prisma generate", @@ -33,6 +39,8 @@ "db:generate": "prisma generate", "db:migrate": "bun scripts/require-local-db.ts db:migrate && prisma migrate dev", "db:push": "bun scripts/require-local-db.ts db:push && prisma db push", + "db:rehearse": "bun scripts/rehearse-migration.ts", + "db:provision": "bun scripts/provision-organization.ts", "db:reset": "bun scripts/require-local-db.ts db:reset && prisma migrate reset", "db:seed": "bun scripts/require-local-db.ts db:seed && prisma db seed", "db:studio": "prisma studio", @@ -45,15 +53,16 @@ }, "dependencies": { "@crm/env": "workspace:*", - "@prisma/adapter-pg": "^7.9.1", - "@prisma/client": "^7.9.1", - "@vercel/blob": "^2.6.1" + "@prisma/adapter-pg": "^7.10.0", + "@prisma/client": "^7.10.0", + "@vercel/blob": "^2.8.0" }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/node": "^24.10.1", - "pg": "^8.22.0", - "prisma": "^7.9.1", - "typescript": "5.9.2" + "@types/bun": "^1.4.2", + "@types/node": "^26.5.0", + "pg": "^8.23.0", + "prisma": "^7.10.0", + "typescript": "7.0.2" } } diff --git a/packages/db/prisma/migrations/20260826190000_xmpp_agent_tasks/migration.sql b/packages/db/prisma/migrations/20260826190000_xmpp_agent_tasks/migration.sql new file mode 100644 index 000000000..e5b37b192 --- /dev/null +++ b/packages/db/prisma/migrations/20260826190000_xmpp_agent_tasks/migration.sql @@ -0,0 +1,33 @@ +CREATE TYPE "XmppAgentTaskState" AS ENUM ('ACCEPTED', 'RUNNING', 'CANCELLING', 'COMPLETED', 'FAILED', 'CANCELLED'); + +CREATE TABLE "xmppAgentTask" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "requestId" TEXT NOT NULL, + "callerJid" TEXT NOT NULL, + "notificationJid" TEXT NOT NULL, + "targetJid" TEXT NOT NULL, + "tool" TEXT NOT NULL, + "apiVersion" TEXT NOT NULL, + "manifestHash" TEXT NOT NULL, + "fingerprint" TEXT NOT NULL, + "arguments" JSONB NOT NULL, + "state" "XmppAgentTaskState" NOT NULL DEFAULT 'ACCEPTED', + "revision" INTEGER NOT NULL DEFAULT 0, + "progress" JSONB, + "result" JSONB, + "error" JSONB, + "summary" TEXT, + "eveSessionId" TEXT, + "deadline" TIMESTAMP(3), + "retainUntil" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "xmppAgentTask_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "xmppAgentTask_organizationId_callerJid_targetJid_requestId_key" ON "xmppAgentTask"("organizationId", "callerJid", "targetJid", "requestId"); +CREATE INDEX "xmppAgentTask_organizationId_state_updatedAt_idx" ON "xmppAgentTask"("organizationId", "state", "updatedAt"); +CREATE INDEX "xmppAgentTask_retainUntil_idx" ON "xmppAgentTask"("retainUntil"); + +ALTER TABLE "xmppAgentTask" ADD CONSTRAINT "xmppAgentTask_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260827180000_xmpp_task_leases/migration.sql b/packages/db/prisma/migrations/20260827180000_xmpp_task_leases/migration.sql new file mode 100644 index 000000000..79e4a56f8 --- /dev/null +++ b/packages/db/prisma/migrations/20260827180000_xmpp_task_leases/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "xmppAgentTask" +ADD COLUMN "ownerId" TEXT, +ADD COLUMN "leaseUntil" TIMESTAMP(3); + +CREATE INDEX "xmppAgentTask_organizationId_state_leaseUntil_idx" +ON "xmppAgentTask"("organizationId", "state", "leaseUntil"); diff --git a/packages/db/prisma/migrations/20260829113915_add_oauth_provider/migration.sql b/packages/db/prisma/migrations/20260829113915_add_oauth_provider/migration.sql new file mode 100644 index 000000000..3b44eb6ed --- /dev/null +++ b/packages/db/prisma/migrations/20260829113915_add_oauth_provider/migration.sql @@ -0,0 +1,206 @@ +CREATE TABLE "jwks" ( + "id" TEXT NOT NULL, + "publicKey" TEXT NOT NULL, + "privateKey" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL, + "expiresAt" TIMESTAMP(3), + "alg" TEXT, + "crv" TEXT, + + CONSTRAINT "jwks_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "oauthClient" ( + "id" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "clientSecret" TEXT, + "clientDiscoveryId" TEXT, + "disabled" BOOLEAN DEFAULT false, + "skipConsent" BOOLEAN, + "enableEndSession" BOOLEAN, + "subjectType" TEXT, + "scopes" TEXT[] NOT NULL, + "clientCredentialsScopes" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "userId" TEXT, + "createdAt" TIMESTAMP(3), + "updatedAt" TIMESTAMP(3), + "name" TEXT, + "uri" TEXT, + "icon" TEXT, + "contacts" TEXT[] NOT NULL, + "tos" TEXT, + "policy" TEXT, + "softwareId" TEXT, + "softwareVersion" TEXT, + "softwareStatement" TEXT, + "redirectUris" TEXT[] NOT NULL, + "postLogoutRedirectUris" TEXT[] NOT NULL, + "backchannelLogoutUri" TEXT, + "backchannelLogoutSessionRequired" BOOLEAN, + "tokenEndpointAuthMethod" TEXT, + "applicationType" TEXT, + "jwks" TEXT, + "jwksUri" TEXT, + "grantTypes" TEXT[] NOT NULL, + "responseTypes" TEXT[] NOT NULL, + "requirePKCE" BOOLEAN, + "dpopBoundAccessTokens" BOOLEAN DEFAULT false, + "referenceId" TEXT, + "metadata" JSONB, + + CONSTRAINT "oauthClient_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "oauthResource" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "name" TEXT NOT NULL, + "accessTokenTtl" INTEGER, + "refreshTokenTtl" INTEGER, + "signingAlgorithm" TEXT, + "signingKeyId" TEXT, + "allowedScopes" TEXT[] NOT NULL, + "customClaims" JSONB, + "dpopBoundAccessTokensRequired" BOOLEAN DEFAULT false, + "disabled" BOOLEAN DEFAULT false, + "createdAt" TIMESTAMP(3), + "updatedAt" TIMESTAMP(3), + "policyVersion" INTEGER DEFAULT 1, + "metadata" JSONB, + + CONSTRAINT "oauthResource_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "oauthClientResource" ( + "id" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "resourceId" TEXT NOT NULL, + "metadata" JSONB, + "createdAt" TIMESTAMP(3), + + CONSTRAINT "oauthClientResource_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "oauthRefreshToken" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "sessionId" TEXT, + "userId" TEXT NOT NULL, + "referenceId" TEXT, + "authorizationCodeId" TEXT, + "resources" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "requestedUserInfoClaims" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3), + "revoked" TIMESTAMP(3), + "rotatedAt" TIMESTAMP(3), + "rotationReplayResponse" TEXT, + "rotationReplayExpiresAt" TIMESTAMP(3), + "authTime" TIMESTAMP(3), + "confirmation" JSONB, + "scopes" TEXT[] NOT NULL, + + CONSTRAINT "oauthRefreshToken_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "oauthAccessToken" ( + "id" TEXT NOT NULL, + "token" TEXT, + "clientId" TEXT NOT NULL, + "sessionId" TEXT, + "userId" TEXT, + "referenceId" TEXT, + "authorizationCodeId" TEXT, + "resources" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "requestedUserInfoClaims" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "refreshId" TEXT, + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3), + "revoked" TIMESTAMP(3), + "confirmation" JSONB, + "scopes" TEXT[] NOT NULL, + + CONSTRAINT "oauthAccessToken_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "oauthConsent" ( + "id" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "userId" TEXT, + "referenceId" TEXT, + "resources" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "requestedUserInfoClaims" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "scopes" TEXT[] NOT NULL, + "createdAt" TIMESTAMP(3), + "updatedAt" TIMESTAMP(3), + + CONSTRAINT "oauthConsent_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "oauthClientAssertion" ( + "id" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "oauthClientAssertion_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "oauthClient_userId_idx" ON "oauthClient"("userId"); + +CREATE UNIQUE INDEX "oauthClient_clientId_key" ON "oauthClient"("clientId"); + +CREATE UNIQUE INDEX "oauthResource_identifier_key" ON "oauthResource"("identifier"); + +CREATE INDEX "oauthClientResource_clientId_idx" ON "oauthClientResource"("clientId"); + +CREATE INDEX "oauthClientResource_resourceId_idx" ON "oauthClientResource"("resourceId"); + +CREATE INDEX "oauthRefreshToken_clientId_idx" ON "oauthRefreshToken"("clientId"); + +CREATE INDEX "oauthRefreshToken_sessionId_idx" ON "oauthRefreshToken"("sessionId"); + +CREATE INDEX "oauthRefreshToken_userId_idx" ON "oauthRefreshToken"("userId"); + +CREATE INDEX "oauthRefreshToken_authorizationCodeId_idx" ON "oauthRefreshToken"("authorizationCodeId"); + +CREATE UNIQUE INDEX "oauthRefreshToken_token_key" ON "oauthRefreshToken"("token"); + +CREATE INDEX "oauthAccessToken_clientId_idx" ON "oauthAccessToken"("clientId"); + +CREATE INDEX "oauthAccessToken_sessionId_idx" ON "oauthAccessToken"("sessionId"); + +CREATE INDEX "oauthAccessToken_userId_idx" ON "oauthAccessToken"("userId"); + +CREATE INDEX "oauthAccessToken_authorizationCodeId_idx" ON "oauthAccessToken"("authorizationCodeId"); + +CREATE INDEX "oauthAccessToken_refreshId_idx" ON "oauthAccessToken"("refreshId"); + +CREATE UNIQUE INDEX "oauthAccessToken_token_key" ON "oauthAccessToken"("token"); + +CREATE INDEX "oauthConsent_clientId_idx" ON "oauthConsent"("clientId"); + +CREATE INDEX "oauthConsent_userId_idx" ON "oauthConsent"("userId"); + +ALTER TABLE "oauthClient" ADD CONSTRAINT "oauthClient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthClientResource" ADD CONSTRAINT "oauthClientResource_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "oauthClient"("clientId") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthClientResource" ADD CONSTRAINT "oauthClientResource_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "oauthResource"("identifier") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthRefreshToken" ADD CONSTRAINT "oauthRefreshToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "oauthClient"("clientId") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthRefreshToken" ADD CONSTRAINT "oauthRefreshToken_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "session"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "oauthRefreshToken" ADD CONSTRAINT "oauthRefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthAccessToken" ADD CONSTRAINT "oauthAccessToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "oauthClient"("clientId") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthAccessToken" ADD CONSTRAINT "oauthAccessToken_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "session"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "oauthAccessToken" ADD CONSTRAINT "oauthAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthAccessToken" ADD CONSTRAINT "oauthAccessToken_refreshId_fkey" FOREIGN KEY ("refreshId") REFERENCES "oauthRefreshToken"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthConsent" ADD CONSTRAINT "oauthConsent_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "oauthClient"("clientId") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthConsent" ADD CONSTRAINT "oauthConsent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260829120000_add_deal_metadata_documents/migration.sql b/packages/db/prisma/migrations/20260829120000_add_deal_metadata_documents/migration.sql new file mode 100644 index 000000000..f8b5bf7fd --- /dev/null +++ b/packages/db/prisma/migrations/20260829120000_add_deal_metadata_documents/migration.sql @@ -0,0 +1,72 @@ +BEGIN; + +ALTER TABLE "deal" + ADD COLUMN "leadSource" TEXT, + ADD COLUMN "projectType" TEXT, + ADD COLUMN "addressLine1" TEXT, + ADD COLUMN "addressLine2" TEXT, + ADD COLUMN "city" TEXT, + ADD COLUMN "state" TEXT, + ADD COLUMN "postalCode" TEXT; + +ALTER TABLE "agentRun" ADD COLUMN "dealId" TEXT; +CREATE INDEX "agentRun_dealId_createdAt_idx" ON "agentRun" ("dealId", "createdAt"); +ALTER TABLE "agentRun" + ADD CONSTRAINT "agentRun_dealId_fkey" + FOREIGN KEY ("dealId") REFERENCES "deal"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE TABLE "artifact" ( + "id" TEXT NOT NULL, + "dealId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "storageKey" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "artifact_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "artifact_dealId_createdAt_idx" ON "artifact" ("dealId", "createdAt"); +ALTER TABLE "artifact" + ADD CONSTRAINT "artifact_dealId_fkey" + FOREIGN KEY ("dealId") REFERENCES "deal"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TABLE "document" ( + "id" TEXT NOT NULL, + "dealId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "number" TEXT NOT NULL, + "status" TEXT NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'USD', + "issuedAt" TIMESTAMP(3), + "dueAt" TIMESTAMP(3), + "recipientSnapshot" JSONB NOT NULL, + "contractorSnapshot" JSONB NOT NULL, + "projectSnapshot" JSONB NOT NULL, + "subtotal" DECIMAL(14,2) NOT NULL, + "tax" DECIMAL(14,2) NOT NULL, + "total" DECIMAL(14,2) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "document_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "document_dealId_createdAt_idx" ON "document" ("dealId", "createdAt"); +ALTER TABLE "document" + ADD CONSTRAINT "document_dealId_fkey" + FOREIGN KEY ("dealId") REFERENCES "deal"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TABLE "documentLineItem" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "description" TEXT NOT NULL, + "quantity" DECIMAL(14,2) NOT NULL, + "unitPrice" DECIMAL(14,2) NOT NULL, + "total" DECIMAL(14,2) NOT NULL, + "position" INTEGER NOT NULL, + CONSTRAINT "documentLineItem_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "documentLineItem_documentId_position_idx" + ON "documentLineItem" ("documentId", "position"); +ALTER TABLE "documentLineItem" + ADD CONSTRAINT "documentLineItem_documentId_fkey" + FOREIGN KEY ("documentId") REFERENCES "document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +COMMIT; diff --git a/packages/db/prisma/migrations/20260830221000_better_auth_account_identity/migration.sql b/packages/db/prisma/migrations/20260830221000_better_auth_account_identity/migration.sql new file mode 100644 index 000000000..524048ced --- /dev/null +++ b/packages/db/prisma/migrations/20260830221000_better_auth_account_identity/migration.sql @@ -0,0 +1,107 @@ +CREATE FUNCTION "better_auth_jwt_payload"(token TEXT) RETURNS JSONB +LANGUAGE plpgsql IMMUTABLE STRICT AS $$ +DECLARE + payload TEXT; +BEGIN + payload := translate(split_part(token, '.', 2), '-_', '+/'); + payload := payload || repeat('=', (4 - length(payload) % 4) % 4); + RETURN convert_from(decode(payload, 'base64'), 'UTF8')::JSONB; +EXCEPTION WHEN OTHERS THEN + RETURN NULL; +END; +$$; + +ALTER TABLE "account" ADD COLUMN "issuer" TEXT; + +UPDATE "account" +SET "issuer" = 'local:credential', + "accountId" = "userId" +WHERE "providerId" = 'credential'; + +UPDATE "account" +SET "issuer" = 'https://accounts.google.com' +WHERE "providerId" = 'google'; + +UPDATE "account" +SET "issuer" = 'local:oauth:slack' +WHERE "providerId" = 'slack'; + +WITH "microsoftIdentity" AS MATERIALIZED ( + SELECT "id", "better_auth_jwt_payload"("idToken") AS "payload" + FROM "account" + WHERE "providerId" = 'microsoft' + AND "idToken" IS NOT NULL +) +UPDATE "account" AS account +SET "issuer" = identity."payload"->>'iss', + "accountId" = identity."payload"->>'oid' +FROM "microsoftIdentity" AS identity +WHERE account."id" = identity."id" + AND jsonb_typeof(identity."payload"->'iss') = 'string' + AND length(identity."payload"->>'iss') > 0 + AND jsonb_typeof(identity."payload"->'oid') = 'string' + AND length(identity."payload"->>'oid') > 0; + +UPDATE "account" AS account +SET "issuer" = provider."issuer" +FROM "ssoProvider" AS provider +WHERE account."providerId" = provider."providerId" + AND account."issuer" IS NULL + AND length(provider."issuer") > 0; + +DELETE FROM "account" AS account +WHERE account."issuer" IS NULL + AND account."providerId" NOT IN ('credential', 'google', 'microsoft', 'slack') + AND NOT EXISTS ( + SELECT 1 + FROM "ssoProvider" AS provider + WHERE provider."providerId" = account."providerId" + ); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "account" + WHERE "issuer" IS NULL OR length("issuer") = 0 + ) THEN + RAISE EXCEPTION 'Better Auth account issuer backfill is incomplete'; + END IF; + IF EXISTS ( + SELECT 1 FROM "account" + WHERE "providerId" = 'microsoft' + AND ( + "idToken" IS NULL + OR "issuer" IS DISTINCT FROM "better_auth_jwt_payload"("idToken")->>'iss' + OR "accountId" IS DISTINCT FROM "better_auth_jwt_payload"("idToken")->>'oid' + ) + ) THEN + RAISE EXCEPTION 'Microsoft account identity needs a verified oid mapping before Better Auth 1.7'; + END IF; + IF EXISTS ( + SELECT 1 + FROM "account" + GROUP BY "issuer", "accountId" + HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'Better Auth account identity backfill found duplicate issuer and accountId pairs'; + END IF; + IF EXISTS ( + SELECT 1 + FROM "account" + GROUP BY "userId", "providerId" + HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'Mailbox account identity backfill found duplicate userId and providerId pairs'; + END IF; +END; +$$; + +ALTER TABLE "account" ALTER COLUMN "issuer" SET NOT NULL; + +CREATE UNIQUE INDEX "account_issuer_accountId_uidx" +ON "account"("issuer", "accountId"); + +CREATE UNIQUE INDEX "account_userId_providerId_uidx" +ON "account"("userId", "providerId"); + +DROP FUNCTION "better_auth_jwt_payload"(TEXT); diff --git a/packages/db/prisma/migrations/20260902181324_kaneo_domain/migration.sql b/packages/db/prisma/migrations/20260902181324_kaneo_domain/migration.sql new file mode 100644 index 000000000..67fa56adc --- /dev/null +++ b/packages/db/prisma/migrations/20260902181324_kaneo_domain/migration.sql @@ -0,0 +1,769 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateTable +CREATE TABLE "user_avatar" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "mime_type" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "data" BYTEA NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_avatar_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workspace" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "logo" TEXT, + "metadata" TEXT, + "description" TEXT, + "created_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "workspace_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workspace_member" ( + "id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'member', + "joined_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "workspace_member_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workspace_billing" ( + "id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "founding_free" BOOLEAN NOT NULL DEFAULT false, + "trial_ends_at" TIMESTAMP(3), + "creem_customer_id" TEXT, + "creem_subscription_id" TEXT, + "creem_product_id" TEXT, + "plan" TEXT, + "billing_interval" TEXT, + "status" TEXT, + "seats" INTEGER NOT NULL DEFAULT 1, + "current_period_end" TIMESTAMP(3), + "canceled_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspace_billing_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "trial_grant" ( + "email_hash" TEXT NOT NULL, + "trial_ends_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "trial_grant_pkey" PRIMARY KEY ("email_hash") +); + +-- CreateTable +CREATE TABLE "billing_event" ( + "id" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_event_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "team" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL, + "updated_at" TIMESTAMP(3), + + CONSTRAINT "team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "team_member" ( + "id" TEXT NOT NULL, + "team_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3), + + CONSTRAINT "team_member_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workspace_invitation" ( + "id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" TEXT, + "team_id" TEXT, + "status" TEXT NOT NULL DEFAULT 'pending', + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "inviter_id" TEXT NOT NULL, + + CONSTRAINT "workspace_invitation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workspace_role" ( + "id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "role" TEXT NOT NULL, + "permission" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspace_role_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project" ( + "id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "icon" TEXT DEFAULT 'Layout', + "name" TEXT NOT NULL, + "description" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "is_public" BOOLEAN DEFAULT false, + "archived_at" TIMESTAMP(3), + "last_task_number" INTEGER NOT NULL DEFAULT 0, + "position" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "column" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "position" INTEGER NOT NULL DEFAULT 0, + "icon" TEXT, + "color" TEXT, + "is_final" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "column_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workflow_rule" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "integration_type" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "column_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workflow_rule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "task" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "position" INTEGER DEFAULT 0, + "number" INTEGER DEFAULT 1, + "assignee_id" TEXT, + "title" TEXT NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL DEFAULT 'to-do', + "column_id" TEXT, + "priority" TEXT NOT NULL DEFAULT 'low', + "start_date" TIMESTAMP(3), + "due_date" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "task_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "billing_reminder_sent" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "reminder_type" TEXT NOT NULL, + "trial_ends_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_reminder_sent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "job_lease" ( + "name" TEXT NOT NULL, + "owner" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "job_lease_pkey" PRIMARY KEY ("name") +); + +-- CreateTable +CREATE TABLE "task_reminder_sent" ( + "id" TEXT NOT NULL, + "task_id" TEXT NOT NULL, + "reminder_type" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "task_reminder_sent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "time_entry" ( + "id" TEXT NOT NULL, + "task_id" TEXT NOT NULL, + "user_id" TEXT, + "description" TEXT, + "start_time" TIMESTAMP(3) NOT NULL, + "end_time" TIMESTAMP(3), + "duration" INTEGER DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "time_entry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "task_activity" ( + "id" TEXT NOT NULL, + "task_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_id" TEXT, + "content" TEXT, + "event_data" JSONB, + "external_user_name" TEXT, + "external_user_avatar" TEXT, + "external_source" TEXT, + "external_url" TEXT, + + CONSTRAINT "task_activity_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "asset" ( + "id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "task_id" TEXT, + "activity_id" TEXT, + "object_key" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "mime_type" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "kind" TEXT NOT NULL DEFAULT 'image', + "surface" TEXT NOT NULL DEFAULT 'description', + "created_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "asset_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "label" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "color" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "task_id" TEXT, + "workspace_id" TEXT, + + CONSTRAINT "label_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notification" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "title" TEXT, + "content" TEXT, + "type" TEXT NOT NULL DEFAULT 'info', + "event_data" JSONB, + "is_read" BOOLEAN DEFAULT false, + "resource_id" TEXT, + "resource_type" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notification_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_notification_preference" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "email_enabled" BOOLEAN NOT NULL DEFAULT false, + "ntfy_enabled" BOOLEAN NOT NULL DEFAULT false, + "ntfy_server_url" TEXT, + "ntfy_topic" TEXT, + "ntfy_token" TEXT, + "gotify_enabled" BOOLEAN NOT NULL DEFAULT false, + "gotify_server_url" TEXT, + "gotify_token" TEXT, + "webhook_enabled" BOOLEAN NOT NULL DEFAULT false, + "webhook_url" TEXT, + "webhook_secret" TEXT, + "task_assignment_enabled" BOOLEAN NOT NULL DEFAULT true, + "task_comment_enabled" BOOLEAN NOT NULL DEFAULT true, + "task_status_change_enabled" BOOLEAN NOT NULL DEFAULT true, + "due_date_reminder_enabled" BOOLEAN NOT NULL DEFAULT true, + "due_date_reminder_lead_time_minutes" INTEGER NOT NULL DEFAULT 1440, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_notification_preference_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_notification_workspace_rule" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "email_enabled" BOOLEAN NOT NULL DEFAULT false, + "ntfy_enabled" BOOLEAN NOT NULL DEFAULT false, + "gotify_enabled" BOOLEAN NOT NULL DEFAULT false, + "webhook_enabled" BOOLEAN NOT NULL DEFAULT false, + "project_mode" TEXT NOT NULL DEFAULT 'all', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_notification_workspace_rule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_notification_workspace_project" ( + "id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "workspace_rule_id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_notification_workspace_project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "github_integration" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "repository_owner" TEXT NOT NULL, + "repository_name" TEXT NOT NULL, + "installation_id" INTEGER, + "is_active" BOOLEAN DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_integration_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "integration" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "config" TEXT NOT NULL, + "is_active" BOOLEAN DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "integration_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "external_link" ( + "id" TEXT NOT NULL, + "task_id" TEXT NOT NULL, + "integration_id" TEXT NOT NULL, + "resource_type" TEXT NOT NULL, + "external_id" TEXT NOT NULL, + "url" TEXT NOT NULL, + "title" TEXT, + "metadata" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "external_link_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "comment" ( + "id" TEXT NOT NULL, + "task_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "content" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "comment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "task_relation" ( + "id" TEXT NOT NULL, + "source_task_id" TEXT NOT NULL, + "target_task_id" TEXT NOT NULL, + "relation_type" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "task_relation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "device_code" ( + "id" TEXT NOT NULL, + "device_code" TEXT NOT NULL, + "user_code" TEXT NOT NULL, + "user_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "status" TEXT NOT NULL, + "last_polled_at" TIMESTAMP(3), + "polling_interval" INTEGER, + "client_id" TEXT, + "scope" TEXT, + + CONSTRAINT "device_code_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "mcp_oauth_state" ( + "id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "key" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "mcp_oauth_state_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_avatar_user_id_unique" ON "user_avatar"("user_id"); + +-- CreateIndex +CREATE INDEX "user_avatar_userId_idx" ON "user_avatar"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_slug_key" ON "workspace"("slug"); + +-- CreateIndex +CREATE INDEX "workspace_member_workspaceId_idx" ON "workspace_member"("workspace_id"); + +-- CreateIndex +CREATE INDEX "workspace_member_userId_idx" ON "workspace_member"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_billing_workspace_id_unique" ON "workspace_billing"("workspace_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_billing_creem_subscription_id_key" ON "workspace_billing"("creem_subscription_id"); + +-- CreateIndex +CREATE INDEX "workspace_billing_workspaceId_idx" ON "workspace_billing"("workspace_id"); + +-- CreateIndex +CREATE INDEX "team_workspaceId_idx" ON "team"("workspace_id"); + +-- CreateIndex +CREATE INDEX "teamMember_teamId_idx" ON "team_member"("team_id"); + +-- CreateIndex +CREATE INDEX "teamMember_userId_idx" ON "team_member"("user_id"); + +-- CreateIndex +CREATE INDEX "workspace_invitation_workspaceId_idx" ON "workspace_invitation"("workspace_id"); + +-- CreateIndex +CREATE INDEX "workspace_invitation_email_idx" ON "workspace_invitation"("email"); + +-- CreateIndex +CREATE INDEX "workspace_invitation_inviterId_idx" ON "workspace_invitation"("inviter_id"); + +-- CreateIndex +CREATE INDEX "workspace_role_workspaceId_idx" ON "workspace_role"("workspace_id"); + +-- CreateIndex +CREATE INDEX "workspace_role_role_idx" ON "workspace_role"("role"); + +-- CreateIndex +CREATE INDEX "project_workspaceId_position_idx" ON "project"("workspace_id", "position"); + +-- CreateIndex +CREATE UNIQUE INDEX "project_workspace_id_id_unique" ON "project"("workspace_id", "id"); + +-- CreateIndex +CREATE INDEX "column_projectId_idx" ON "column"("project_id"); + +-- CreateIndex +CREATE INDEX "workflow_rule_projectId_idx" ON "workflow_rule"("project_id"); + +-- CreateIndex +CREATE INDEX "workflow_rule_columnId_idx" ON "workflow_rule"("column_id"); + +-- CreateIndex +CREATE INDEX "task_projectId_idx" ON "task"("project_id"); + +-- CreateIndex +CREATE INDEX "task_dueDate_idx" ON "task"("due_date"); + +-- CreateIndex +CREATE INDEX "task_assigneeId_idx" ON "task"("assignee_id"); + +-- CreateIndex +CREATE INDEX "task_columnId_idx" ON "task"("column_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "task_project_number_unique" ON "task"("project_id", "number"); + +-- CreateIndex +CREATE INDEX "billing_reminder_sent_workspaceId_idx" ON "billing_reminder_sent"("workspace_id"); + +-- CreateIndex +CREATE INDEX "billing_reminder_sent_userId_idx" ON "billing_reminder_sent"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "billing_reminder_sent_user_type_unique" ON "billing_reminder_sent"("user_id", "reminder_type"); + +-- CreateIndex +CREATE INDEX "task_reminder_sent_taskId_idx" ON "task_reminder_sent"("task_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "task_reminder_sent_task_type_unique" ON "task_reminder_sent"("task_id", "reminder_type"); + +-- CreateIndex +CREATE INDEX "time_entry_taskId_idx" ON "time_entry"("task_id"); + +-- CreateIndex +CREATE INDEX "time_entry_userId_idx" ON "time_entry"("user_id"); + +-- CreateIndex +CREATE INDEX "activity_task_id_idx" ON "task_activity"("task_id"); + +-- CreateIndex +CREATE INDEX "activity_userId_idx" ON "task_activity"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "activity_task_external_source_external_url_unique" ON "task_activity"("task_id", "external_source", "external_url"); + +-- CreateIndex +CREATE UNIQUE INDEX "asset_object_key_key" ON "asset"("object_key"); + +-- CreateIndex +CREATE INDEX "asset_workspaceId_idx" ON "asset"("workspace_id"); + +-- CreateIndex +CREATE INDEX "asset_projectId_idx" ON "asset"("project_id"); + +-- CreateIndex +CREATE INDEX "asset_taskId_idx" ON "asset"("task_id"); + +-- CreateIndex +CREATE INDEX "asset_activityId_idx" ON "asset"("activity_id"); + +-- CreateIndex +CREATE INDEX "asset_createdBy_idx" ON "asset"("created_by"); + +-- CreateIndex +CREATE INDEX "label_task_id_idx" ON "label"("task_id"); + +-- CreateIndex +CREATE INDEX "label_workspace_id_idx" ON "label"("workspace_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "label_task_name_unique" ON "label"("task_id", "name"); + +-- CreateIndex +CREATE INDEX "notification_userId_idx" ON "notification"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_notification_preference_user_id_key" ON "user_notification_preference"("user_id"); + +-- CreateIndex +CREATE INDEX "user_notification_workspace_rule_userId_idx" ON "user_notification_workspace_rule"("user_id"); + +-- CreateIndex +CREATE INDEX "user_notification_workspace_rule_workspaceId_idx" ON "user_notification_workspace_rule"("workspace_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_notification_workspace_rule_user_workspace_unique" ON "user_notification_workspace_rule"("user_id", "workspace_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_notification_workspace_rule_workspace_id_id_unique" ON "user_notification_workspace_rule"("workspace_id", "id"); + +-- CreateIndex +CREATE INDEX "user_notification_workspace_project_ruleId_idx" ON "user_notification_workspace_project"("workspace_rule_id"); + +-- CreateIndex +CREATE INDEX "user_notification_workspace_project_projectId_idx" ON "user_notification_workspace_project"("project_id"); + +-- CreateIndex +CREATE INDEX "user_notification_workspace_project_workspaceId_projectId_idx" ON "user_notification_workspace_project"("workspace_id", "project_id"); + +-- CreateIndex +CREATE INDEX "unwp_workspaceId_workspaceRuleId_idx" ON "user_notification_workspace_project"("workspace_id", "workspace_rule_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_notification_workspace_project_rule_project_unique" ON "user_notification_workspace_project"("workspace_rule_id", "project_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "github_integration_project_id_key" ON "github_integration"("project_id"); + +-- CreateIndex +CREATE INDEX "integration_projectId_idx" ON "integration"("project_id"); + +-- CreateIndex +CREATE INDEX "integration_type_idx" ON "integration"("type"); + +-- CreateIndex +CREATE UNIQUE INDEX "integration_project_type_unique" ON "integration"("project_id", "type"); + +-- CreateIndex +CREATE INDEX "external_link_taskId_idx" ON "external_link"("task_id"); + +-- CreateIndex +CREATE INDEX "external_link_integrationId_idx" ON "external_link"("integration_id"); + +-- CreateIndex +CREATE INDEX "external_link_externalId_idx" ON "external_link"("external_id"); + +-- CreateIndex +CREATE INDEX "external_link_resourceType_idx" ON "external_link"("resource_type"); + +-- CreateIndex +CREATE INDEX "comment_task_idx" ON "comment"("task_id"); + +-- CreateIndex +CREATE INDEX "comment_user_idx" ON "comment"("user_id"); + +-- CreateIndex +CREATE INDEX "task_relation_source_idx" ON "task_relation"("source_task_id"); + +-- CreateIndex +CREATE INDEX "task_relation_target_idx" ON "task_relation"("target_task_id"); + +-- CreateIndex +CREATE INDEX "device_code_user_id_idx" ON "device_code"("user_id"); + +-- CreateIndex +CREATE INDEX "mcp_oauth_state_expiresAt_idx" ON "mcp_oauth_state"("expires_at"); + +-- AddForeignKey +ALTER TABLE "workspace_member" ADD CONSTRAINT "workspace_member_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_billing" ADD CONSTRAINT "workspace_billing_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "team" ADD CONSTRAINT "team_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "team_member" ADD CONSTRAINT "team_member_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_invitation" ADD CONSTRAINT "workspace_invitation_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_role" ADD CONSTRAINT "workspace_role_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project" ADD CONSTRAINT "project_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "column" ADD CONSTRAINT "column_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workflow_rule" ADD CONSTRAINT "workflow_rule_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workflow_rule" ADD CONSTRAINT "workflow_rule_column_id_fkey" FOREIGN KEY ("column_id") REFERENCES "column"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "task" ADD CONSTRAINT "task_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "task" ADD CONSTRAINT "task_column_id_fkey" FOREIGN KEY ("column_id") REFERENCES "column"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "billing_reminder_sent" ADD CONSTRAINT "billing_reminder_sent_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "task_reminder_sent" ADD CONSTRAINT "task_reminder_sent_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "time_entry" ADD CONSTRAINT "time_entry_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "task_activity" ADD CONSTRAINT "task_activity_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "asset" ADD CONSTRAINT "asset_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "asset" ADD CONSTRAINT "asset_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "asset" ADD CONSTRAINT "asset_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "asset" ADD CONSTRAINT "asset_activity_id_fkey" FOREIGN KEY ("activity_id") REFERENCES "task_activity"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "label" ADD CONSTRAINT "label_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "label" ADD CONSTRAINT "label_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_notification_workspace_rule" ADD CONSTRAINT "user_notification_workspace_rule_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_notification_workspace_project" ADD CONSTRAINT "user_notification_workspace_project_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_notification_workspace_project" ADD CONSTRAINT "user_notification_workspace_project_workspace_id_workspace_fkey" FOREIGN KEY ("workspace_id", "workspace_rule_id") REFERENCES "user_notification_workspace_rule"("workspace_id", "id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_notification_workspace_project" ADD CONSTRAINT "user_notification_workspace_project_workspace_id_project_i_fkey" FOREIGN KEY ("workspace_id", "project_id") REFERENCES "project"("workspace_id", "id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "github_integration" ADD CONSTRAINT "github_integration_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "integration" ADD CONSTRAINT "integration_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "external_link" ADD CONSTRAINT "external_link_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "external_link" ADD CONSTRAINT "external_link_integration_id_fkey" FOREIGN KEY ("integration_id") REFERENCES "integration"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "comment" ADD CONSTRAINT "comment_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "task_relation" ADD CONSTRAINT "task_relation_source_task_id_fkey" FOREIGN KEY ("source_task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "task_relation" ADD CONSTRAINT "task_relation_target_task_id_fkey" FOREIGN KEY ("target_task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/packages/db/prisma/migrations/20260902182231_kaneo_auth_columns/migration.sql b/packages/db/prisma/migrations/20260902182231_kaneo_auth_columns/migration.sql new file mode 100644 index 000000000..074da25c9 --- /dev/null +++ b/packages/db/prisma/migrations/20260902182231_kaneo_auth_columns/migration.sql @@ -0,0 +1,8 @@ +ALTER TABLE "user" ADD COLUMN "locale" TEXT; +ALTER TABLE "user" ADD COLUMN "role" TEXT; +ALTER TABLE "user" ADD COLUMN "isAnonymous" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "user" ADD COLUMN "banned" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "user" ADD COLUMN "banReason" TEXT; +ALTER TABLE "user" ADD COLUMN "banExpires" TIMESTAMP(3); +ALTER TABLE "session" ADD COLUMN "activeTeamId" TEXT; +ALTER TABLE "session" ADD COLUMN "impersonatedBy" TEXT; diff --git a/packages/db/prisma/migrations/20260902213708_kaneo_comment_user_nullable/migration.sql b/packages/db/prisma/migrations/20260902213708_kaneo_comment_user_nullable/migration.sql new file mode 100644 index 000000000..b6677bd8c --- /dev/null +++ b/packages/db/prisma/migrations/20260902213708_kaneo_comment_user_nullable/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "comment" ALTER COLUMN "user_id" DROP NOT NULL; diff --git a/packages/db/prisma/migrations/20260903120000_push_tokens/migration.sql b/packages/db/prisma/migrations/20260903120000_push_tokens/migration.sql new file mode 100644 index 000000000..e8a516a8e --- /dev/null +++ b/packages/db/prisma/migrations/20260903120000_push_tokens/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "push_token" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "platform" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "push_token_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "push_token_token_unique" ON "push_token"("token"); + +-- CreateIndex +CREATE INDEX "push_token_userId_idx" ON "push_token"("user_id"); + +-- AddForeignKey +ALTER TABLE "push_token" ADD CONSTRAINT "push_token_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260905120000_multi_tenant_schema/migration.sql b/packages/db/prisma/migrations/20260905120000_multi_tenant_schema/migration.sql new file mode 100644 index 000000000..c0e196206 --- /dev/null +++ b/packages/db/prisma/migrations/20260905120000_multi_tenant_schema/migration.sql @@ -0,0 +1,330 @@ +INSERT INTO "organization" ("id", "name", "slug", "createdAt") +VALUES ('workspace', 'Workspace', 'workspace', CURRENT_TIMESTAMP) +ON CONFLICT ("id") DO NOTHING; + +ALTER TABLE "company" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "contact" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "deal" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "dealContact" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "activity" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "fieldDefinition" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "fieldOption" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "fieldValue" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "companyEnrichment" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "contactFact" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "contactBrief" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "agentTask" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "agentEvent" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "agentConversation" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "agentConversationFeedback" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "agentConversationShare" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "agentConversationSubmission" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "agentConversationAttachment" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "agentDefinition" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "agentVersion" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "agentBuilderArtifact" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "agentTrigger" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "agentRun" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "agentRunEvent" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "agentAction" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "agentAuditEvent" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "mailboxSync" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "emailThread" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "emailMessage" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "calendarEvent" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "calendarAttendee" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "slackInstallation" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "slackWorkspaceGrant" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "slackChannel" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "slackMemberMatch" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "trackedDomain" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "trackedVisitor" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "trackedEvent" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "trackingCounter" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "trackedPageDaily" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "formSubmission" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "savedView" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "suppressedDomain" DROP CONSTRAINT "suppressedDomain_pkey"; +ALTER TABLE "suppressedDomain" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "suppressedDomain" ADD CONSTRAINT "suppressedDomain_pkey" PRIMARY KEY ("organizationId", "domain"); +ALTER TABLE "suppressedContact" DROP CONSTRAINT "suppressedContact_pkey"; +ALTER TABLE "suppressedContact" ADD COLUMN "organizationId" TEXT NOT NULL DEFAULT 'workspace'; +ALTER TABLE "suppressedContact" ADD CONSTRAINT "suppressedContact_pkey" PRIMARY KEY ("organizationId", "email"); +ALTER TABLE "trackedPageDaily" DROP CONSTRAINT "trackedPageDaily_pkey"; +ALTER TABLE "trackedPageDaily" ADD CONSTRAINT "trackedPageDaily_pkey" PRIMARY KEY ("organizationId", "day", "host", "path"); +ALTER TABLE "appSetting" RENAME COLUMN "id" TO "organizationId"; +UPDATE "appSetting" SET "organizationId" = 'workspace' WHERE "organizationId" = 'app'; +ALTER TABLE "workspaceProfile" RENAME COLUMN "id" TO "organizationId"; +UPDATE "workspaceProfile" SET "organizationId" = 'workspace'; +DROP INDEX "company_domain_active_key"; +DROP INDEX "contact_email_active_key"; +DROP INDEX "fieldDefinition_entity_key_key"; +DROP INDEX "trackedDomain_host_key"; +DROP INDEX "savedView_entity_ownerId_name_key"; +CREATE UNIQUE INDEX "company_organization_domain_active_key" ON "company"("organizationId", "domain") WHERE ("archivedAt" IS NULL); +CREATE UNIQUE INDEX "contact_organization_email_active_key" ON "contact"("organizationId", "email") WHERE ("archivedAt" IS NULL); +CREATE UNIQUE INDEX "fieldDefinition_organizationId_entity_key_key" ON "fieldDefinition"("organizationId", "entity", "key"); +CREATE UNIQUE INDEX "trackedDomain_organizationId_host_key" ON "trackedDomain"("organizationId", "host"); +CREATE UNIQUE INDEX "savedView_organizationId_entity_ownerId_name_key" ON "savedView"("organizationId", "entity", "ownerId", "name"); + +DO $$ +DECLARE + table_name TEXT; +BEGIN + FOREACH table_name IN ARRAY ARRAY[ + 'company', 'contact', 'deal', 'dealContact', 'activity', 'fieldDefinition', 'fieldOption', 'fieldValue', + 'companyEnrichment', 'contactFact', 'contactBrief', 'agentTask', 'agentEvent', 'agentConversation', + 'agentConversationFeedback', 'agentConversationShare', 'agentConversationSubmission', 'agentConversationAttachment', + 'agentDefinition', 'agentVersion', 'agentBuilderArtifact', 'agentTrigger', 'agentRun', 'agentRunEvent', + 'agentAction', 'agentAuditEvent', 'mailboxSync', 'emailThread', 'emailMessage', 'calendarEvent', + 'calendarAttendee', 'slackInstallation', 'slackWorkspaceGrant', 'slackChannel', 'slackMemberMatch', + 'trackedDomain', 'trackedVisitor', 'trackedEvent', 'trackingCounter', 'trackedPageDaily', 'formSubmission', 'savedView', + 'suppressedDomain', 'suppressedContact' + ] + LOOP + EXECUTE format('ALTER TABLE %I ALTER COLUMN "organizationId" DROP DEFAULT', table_name); + EXECUTE format('ALTER TABLE %I ADD CONSTRAINT %I FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE', table_name, table_name || '_organizationId_fkey'); + END LOOP; +END $$; + +ALTER TABLE "appSetting" ADD CONSTRAINT "appSetting_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "workspaceProfile" ADD CONSTRAINT "workspaceProfile_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +DO $$ +DECLARE + table_name TEXT; +BEGIN + FOREACH table_name IN ARRAY ARRAY[ + 'company', 'contact', 'deal', 'dealContact', 'activity', 'fieldDefinition', 'fieldOption', 'fieldValue', + 'companyEnrichment', 'contactFact', 'contactBrief', 'agentTask', 'agentEvent', 'agentConversation', + 'agentConversationFeedback', 'agentConversationShare', 'agentConversationSubmission', 'agentConversationAttachment', + 'agentDefinition', 'agentVersion', 'agentBuilderArtifact', 'agentTrigger', 'agentRun', 'agentRunEvent', + 'agentAction', 'agentAuditEvent', 'mailboxSync', 'emailThread', 'emailMessage', 'calendarEvent', + 'calendarAttendee', 'slackChannel', 'slackMemberMatch', 'trackedDomain', 'trackedVisitor', + 'trackedEvent', 'formSubmission', 'savedView' + ] + LOOP + EXECUTE format('CREATE INDEX %I ON %I ("organizationId")', table_name || '_organizationId_idx', table_name); + END LOOP; +END $$; + +ALTER TABLE "trackingCounter" DROP CONSTRAINT "trackingCounter_pkey"; +ALTER TABLE "trackingCounter" ADD CONSTRAINT "trackingCounter_pkey" PRIMARY KEY ("organizationId", "key"); + +DROP INDEX "mailboxSync_userId_source_key"; +CREATE UNIQUE INDEX "mailboxSync_organizationId_userId_source_key" ON "mailboxSync"("organizationId", "userId", "source"); + +ALTER TABLE "ssoProvider" ALTER COLUMN "organizationId" SET DEFAULT 'workspace'; +UPDATE "ssoProvider" SET "organizationId" = 'workspace' WHERE "organizationId" IS NULL; +ALTER TABLE "ssoProvider" ALTER COLUMN "organizationId" SET NOT NULL; +ALTER TABLE "ssoProvider" ALTER COLUMN "organizationId" DROP DEFAULT; + +CREATE INDEX "ssoProvider_organizationId_idx" ON "ssoProvider"("organizationId"); + +ALTER TABLE "ssoProvider" ADD CONSTRAINT "ssoProvider_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "apikey" DROP CONSTRAINT "apikey_referenceId_fkey"; + +WITH "resolved" AS ( + SELECT + "apikey"."id", + "apikey"."referenceId" AS "userId", + ( + SELECT "member"."organizationId" + FROM "member" + WHERE "member"."userId" = "apikey"."referenceId" + ORDER BY "member"."createdAt" ASC, "member"."id" ASC + LIMIT 1 + ) AS "organizationId" + FROM "apikey" +) +UPDATE "apikey" +SET + "referenceId" = "resolved"."organizationId", + "metadata" = jsonb_build_object( + 'createdByUserId', + "resolved"."userId" + )::text +FROM "resolved" +WHERE "apikey"."id" = "resolved"."id" + AND "resolved"."organizationId" IS NOT NULL; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM "apikey" + LEFT JOIN "organization" + ON "organization"."id" = "apikey"."referenceId" + WHERE "organization"."id" IS NULL + ) THEN + RAISE EXCEPTION 'Every API key creator must belong to an organization'; + END IF; +END $$; + +ALTER TABLE "apikey" +ADD CONSTRAINT "apikey_referenceId_fkey" +FOREIGN KEY ("referenceId") REFERENCES "organization"("id") +ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "slackInstallation" +ADD COLUMN "botToken" TEXT, +ADD COLUMN "botScopes" TEXT NOT NULL DEFAULT ''; + +ALTER TABLE "slackWorkspaceGrant" +ADD COLUMN "botToken" TEXT, +ADD COLUMN "botScopes" TEXT NOT NULL DEFAULT ''; + +WITH "latestSlackAccount" AS ( + SELECT "accessToken", COALESCE("scope", '') AS "scope" + FROM "account" + WHERE "providerId" = 'slack' AND "accessToken" IS NOT NULL + ORDER BY "updatedAt" DESC + LIMIT 1 +) +UPDATE "slackWorkspaceGrant" +SET + "botToken" = "latestSlackAccount"."accessToken", + "botScopes" = "latestSlackAccount"."scope" +FROM "latestSlackAccount" +WHERE "slackWorkspaceGrant"."botToken" IS NULL; + +DO $$ +DECLARE + tbl text; +BEGIN + FOREACH tbl IN ARRAY ARRAY[ + 'company', + 'contact', + 'deal', + 'dealContact', + 'activity', + 'fieldDefinition', + 'fieldOption', + 'fieldValue', + 'companyEnrichment', + 'contactFact', + 'contactBrief', + 'agentTask', + 'agentEvent', + 'agentConversation', + 'agentConversationFeedback', + 'agentConversationShare', + 'agentConversationSubmission', + 'agentConversationAttachment', + 'agentDefinition', + 'agentVersion', + 'agentBuilderArtifact', + 'agentTrigger', + 'agentRun', + 'agentRunEvent', + 'agentAction', + 'agentAuditEvent', + 'mailboxSync', + 'emailThread', + 'emailMessage', + 'calendarEvent', + 'calendarAttendee', + 'appSetting', + 'workspaceProfile', + 'ssoProvider', + 'slackInstallation', + 'slackWorkspaceGrant', + 'slackChannel', + 'slackMemberMatch', + 'trackedDomain', + 'trackedVisitor', + 'trackedEvent', + 'trackedPageDaily', + 'formSubmission', + 'trackingCounter', + 'suppressedDomain', + 'suppressedContact', + 'savedView' + ] + LOOP + EXECUTE format( + 'ALTER TABLE %I ALTER COLUMN "organizationId" SET DEFAULT current_setting(''app.current_organization_id''::text, true)', + tbl + ); + EXECUTE format( + 'CREATE POLICY tenant_isolation ON %I USING ("organizationId" = current_setting(''app.current_organization_id''::text, true)) WITH CHECK ("organizationId" = current_setting(''app.current_organization_id''::text, true))', + tbl + ); + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', tbl); + END LOOP; +END $$; + +DROP INDEX "slackWorkspaceGrant_teamId_key"; + +ALTER TABLE "slackInstallation" +DROP CONSTRAINT "slackInstallation_pkey", +ADD CONSTRAINT "slackInstallation_pkey" PRIMARY KEY ("organizationId", "installerId"); + +CREATE UNIQUE INDEX "slackWorkspaceGrant_organizationId_teamId_key" +ON "slackWorkspaceGrant"("organizationId", "teamId"); + +CREATE TABLE "trackingSiteLocator" ( + "siteId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + + CONSTRAINT "trackingSiteLocator_pkey" PRIMARY KEY ("siteId") +); + +CREATE UNIQUE INDEX "trackingSiteLocator_organizationId_key" +ON "trackingSiteLocator"("organizationId"); + +ALTER TABLE "trackingSiteLocator" +ADD CONSTRAINT "trackingSiteLocator_organizationId_fkey" +FOREIGN KEY ("organizationId") REFERENCES "organization"("id") +ON DELETE CASCADE ON UPDATE CASCADE; + +INSERT INTO "trackingSiteLocator" ("siteId", "organizationId") +SELECT "trackingSiteId", "organizationId" +FROM "appSetting" +WHERE "trackingSiteId" IS NOT NULL; + +CREATE TABLE "ssoProviderLocator" ( + "providerId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "domain" TEXT NOT NULL, + + CONSTRAINT "ssoProviderLocator_pkey" PRIMARY KEY ("providerId") +); + +CREATE INDEX "ssoProviderLocator_organizationId_idx" +ON "ssoProviderLocator"("organizationId"); + +ALTER TABLE "ssoProviderLocator" +ADD CONSTRAINT "ssoProviderLocator_providerId_fkey" +FOREIGN KEY ("providerId") REFERENCES "ssoProvider"("providerId") +ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "ssoProviderLocator" +ADD CONSTRAINT "ssoProviderLocator_organizationId_fkey" +FOREIGN KEY ("organizationId") REFERENCES "organization"("id") +ON DELETE CASCADE ON UPDATE CASCADE; + +INSERT INTO "ssoProviderLocator" ("providerId", "organizationId", "domain") +SELECT "providerId", "organizationId", "domain" +FROM "ssoProvider"; + +CREATE FUNCTION "syncSsoProviderLocator"() RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + INSERT INTO "ssoProviderLocator" ("providerId", "organizationId", "domain") + VALUES (NEW."providerId", NEW."organizationId", NEW."domain") + ON CONFLICT ("providerId") DO UPDATE + SET "organizationId" = EXCLUDED."organizationId", + "domain" = EXCLUDED."domain"; + RETURN NEW; +END; +$$; + +CREATE TRIGGER "ssoProviderLocatorSync" +AFTER INSERT OR UPDATE OF "providerId", "organizationId", "domain" +ON "ssoProvider" +FOR EACH ROW +EXECUTE FUNCTION "syncSsoProviderLocator"(); diff --git a/packages/db/prisma/migrations/20260907160000_customer_project_assets/migration.sql b/packages/db/prisma/migrations/20260907160000_customer_project_assets/migration.sql new file mode 100644 index 000000000..17cfb2ffb --- /dev/null +++ b/packages/db/prisma/migrations/20260907160000_customer_project_assets/migration.sql @@ -0,0 +1,148 @@ +CREATE TYPE "AssetSource" AS ENUM ('MANUAL', 'MOBILE_RECORDING', 'EMAIL_ATTACHMENT'); + +CREATE TYPE "AssetStatus" AS ENUM ('UNVERIFIED', 'READY', 'DELETING', 'DELETED'); + +CREATE TYPE "AssetUploadStatus" AS ENUM ('PENDING', 'FINALIZING', 'READY', 'FAILED', 'CANCELED', 'EXPIRED'); + +CREATE TYPE "AssetJobOperation" AS ENUM ('FINALIZE_UPLOAD', 'DELETE_OBJECT'); + +CREATE TYPE "AssetJobState" AS ENUM ('PENDING', 'RUNNING', 'COMPLETE'); + +ALTER TABLE "artifact" ADD COLUMN "activityId" TEXT, +ADD COLUMN "capturedAt" TIMESTAMP(3), +ADD COLUMN "contentType" TEXT NOT NULL DEFAULT 'application/octet-stream', +ADD COLUMN "deletedAt" TIMESTAMP(3), +ADD COLUMN "durationMilliseconds" BIGINT, +ADD COLUMN "emailAttachmentId" TEXT, +ADD COLUMN "emailMessageId" TEXT, +ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'file', +ADD COLUMN "sizeBytes" BIGINT, +ADD COLUMN "source" "AssetSource", +ADD COLUMN "status" "AssetStatus" NOT NULL DEFAULT 'UNVERIFIED', +ADD COLUMN "storageBucket" TEXT, +ADD COLUMN "uploadedById" TEXT; + +CREATE TABLE "assetUpload" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "actorKey" TEXT NOT NULL, + "uploadedById" TEXT, + "mailboxOwnerId" TEXT, + "fileName" TEXT NOT NULL, + "contentType" TEXT NOT NULL, + "sizeBytes" BIGINT NOT NULL, + "kind" TEXT NOT NULL, + "source" "AssetSource" NOT NULL, + "activityId" TEXT, + "durationMilliseconds" BIGINT, + "capturedAt" TIMESTAMP(3), + "emailMessageId" TEXT, + "emailAttachmentId" TEXT, + "metadataHash" TEXT NOT NULL, + "bucket" TEXT NOT NULL, + "temporaryKey" TEXT NOT NULL, + "finalKey" TEXT NOT NULL, + "sourceEtag" TEXT, + "status" "AssetUploadStatus" NOT NULL DEFAULT 'PENDING', + "assetId" TEXT, + "failureCode" TEXT, + "failureMessage" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "grantExpiresAt" TIMESTAMP(3) NOT NULL, + "reservationUntil" TIMESTAMP(3) NOT NULL, + "reservationReleasedAt" TIMESTAMP(3), + "confirmedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "assetUpload_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "assetEmailSource" ( + "id" TEXT NOT NULL, + "messageId" TEXT NOT NULL, + "attachmentId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "metadataHash" TEXT NOT NULL, + "uploadId" TEXT NOT NULL, + "assetId" TEXT, + "deletedAt" TIMESTAMP(3), + "mailboxOwnerId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "assetEmailSource_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "assetStorageJob" ( + "id" TEXT NOT NULL, + "operationKey" TEXT NOT NULL, + "operation" "AssetJobOperation" NOT NULL, + "projectId" TEXT NOT NULL, + "uploadId" TEXT, + "artifactId" TEXT, + "bucket" TEXT, + "objectKey" TEXT NOT NULL, + "finalKey" TEXT, + "temporary" BOOLEAN NOT NULL DEFAULT false, + "state" "AssetJobState" NOT NULL DEFAULT 'PENDING', + "attempts" INTEGER NOT NULL DEFAULT 0, + "nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "leaseUntil" TIMESTAMP(3), + "leaseToken" TEXT, + "lastError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "assetStorageJob_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "assetApiRequest" ( + "id" TEXT NOT NULL, + "actorKey" TEXT NOT NULL, + "operation" TEXT NOT NULL, + "path" TEXT NOT NULL, + "idempotencyKey" TEXT NOT NULL, + "requestHash" TEXT NOT NULL, + "responseStatus" INTEGER NOT NULL, + "responseBody" JSONB NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "assetApiRequest_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "assetUpload_temporaryKey_key" ON "assetUpload"("temporaryKey"); + +CREATE UNIQUE INDEX "assetUpload_finalKey_key" ON "assetUpload"("finalKey"); + +CREATE UNIQUE INDEX "assetUpload_assetId_key" ON "assetUpload"("assetId"); + +CREATE INDEX "assetUpload_actorKey_reservationReleasedAt_idx" ON "assetUpload"("actorKey", "reservationReleasedAt"); + +CREATE INDEX "assetUpload_projectId_idx" ON "assetUpload"("projectId"); + +CREATE INDEX "assetUpload_status_expiresAt_idx" ON "assetUpload"("status", "expiresAt"); + +CREATE INDEX "assetEmailSource_projectId_idx" ON "assetEmailSource"("projectId"); + +CREATE UNIQUE INDEX "assetEmailSource_messageId_attachmentId_key" ON "assetEmailSource"("messageId", "attachmentId"); + +CREATE UNIQUE INDEX "assetStorageJob_operationKey_key" ON "assetStorageJob"("operationKey"); + +CREATE INDEX "assetStorageJob_state_nextAttemptAt_leaseUntil_idx" ON "assetStorageJob"("state", "nextAttemptAt", "leaseUntil"); + +CREATE INDEX "assetStorageJob_projectId_idx" ON "assetStorageJob"("projectId"); + +CREATE INDEX "assetApiRequest_expiresAt_idx" ON "assetApiRequest"("expiresAt"); + +CREATE UNIQUE INDEX "assetApiRequest_actorKey_operation_path_idempotencyKey_key" ON "assetApiRequest"("actorKey", "operation", "path", "idempotencyKey"); + +CREATE INDEX "artifact_dealId_status_createdAt_id_idx" ON "artifact"("dealId", "status", "createdAt", "id"); + +CREATE UNIQUE INDEX "artifact_storageBucket_storageKey_key" ON "artifact"("storageBucket", "storageKey"); + + +UPDATE "artifact" SET "kind" = CASE WHEN length("type") BETWEEN 1 AND 64 THEN "type" ELSE 'file' END; diff --git a/packages/db/prisma/migrations/20260909120000_tenant_scope_assets/migration.sql b/packages/db/prisma/migrations/20260909120000_tenant_scope_assets/migration.sql new file mode 100644 index 000000000..ebd78896f --- /dev/null +++ b/packages/db/prisma/migrations/20260909120000_tenant_scope_assets/migration.sql @@ -0,0 +1,79 @@ +ALTER TABLE "artifact" ADD COLUMN "organizationId" TEXT; +ALTER TABLE "assetUpload" ADD COLUMN "organizationId" TEXT; +ALTER TABLE "assetEmailSource" ADD COLUMN "organizationId" TEXT; +ALTER TABLE "assetStorageJob" ADD COLUMN "organizationId" TEXT; +ALTER TABLE "assetApiRequest" ADD COLUMN "organizationId" TEXT; + +UPDATE "artifact" +SET "organizationId" = "deal"."organizationId" +FROM "deal" +WHERE "artifact"."dealId" = "deal"."id"; + +UPDATE "assetUpload" +SET "organizationId" = "deal"."organizationId" +FROM "deal" +WHERE "assetUpload"."projectId" = "deal"."id"; + +UPDATE "assetEmailSource" +SET "organizationId" = "deal"."organizationId" +FROM "deal" +WHERE "assetEmailSource"."projectId" = "deal"."id"; + +UPDATE "assetStorageJob" +SET "organizationId" = "deal"."organizationId" +FROM "deal" +WHERE "assetStorageJob"."projectId" = "deal"."id"; + +UPDATE "assetApiRequest" +SET "organizationId" = "deal"."organizationId" +FROM "deal" +WHERE "deal"."id" = substring("assetApiRequest"."path" FROM '^/projects/([^/]+)'); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "artifact" WHERE "organizationId" IS NULL + UNION ALL + SELECT 1 FROM "assetUpload" WHERE "organizationId" IS NULL + UNION ALL + SELECT 1 FROM "assetEmailSource" WHERE "organizationId" IS NULL + UNION ALL + SELECT 1 FROM "assetStorageJob" WHERE "organizationId" IS NULL + UNION ALL + SELECT 1 FROM "assetApiRequest" WHERE "organizationId" IS NULL + ) THEN + RAISE EXCEPTION 'Asset tenant backfill failed'; + END IF; +END $$; + +DO $$ +DECLARE + tbl text; +BEGIN + FOREACH tbl IN ARRAY ARRAY[ + 'artifact', + 'assetUpload', + 'assetEmailSource', + 'assetStorageJob', + 'assetApiRequest' + ] + LOOP + EXECUTE format('ALTER TABLE %I ALTER COLUMN "organizationId" SET NOT NULL', tbl); + EXECUTE format( + 'ALTER TABLE %I ALTER COLUMN "organizationId" SET DEFAULT current_setting(''app.current_organization_id''::text, true)', + tbl + ); + EXECUTE format( + 'ALTER TABLE %I ADD CONSTRAINT %I FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE', + tbl, + tbl || '_organizationId_fkey' + ); + EXECUTE format('CREATE INDEX %I ON %I ("organizationId")', tbl || '_organizationId_idx', tbl); + EXECUTE format( + 'CREATE POLICY "tenantIsolation" ON %I USING ("organizationId" = current_setting(''app.current_organization_id''::text, true)) WITH CHECK ("organizationId" = current_setting(''app.current_organization_id''::text, true))', + tbl + ); + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', tbl); + END LOOP; +END $$; diff --git a/packages/db/prisma/migrations/20260910160000_managed_appointments/migration.sql b/packages/db/prisma/migrations/20260910160000_managed_appointments/migration.sql new file mode 100644 index 000000000..734d12a44 --- /dev/null +++ b/packages/db/prisma/migrations/20260910160000_managed_appointments/migration.sql @@ -0,0 +1,41 @@ +CREATE TYPE "AppointmentStatus" AS ENUM ('SCHEDULED', 'COMPLETED', 'CANCELED'); + +ALTER TABLE "activity" ADD COLUMN "archivedAt" TIMESTAMP(3); +CREATE INDEX "activity_archivedAt_idx" ON "activity"("archivedAt"); + +CREATE TABLE "appointmentDetails" ( + "activityId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL DEFAULT current_setting('app.current_organization_id'::text, true), + "startsAt" TIMESTAMP(3) NOT NULL, + "endsAt" TIMESTAMP(3), + "timeZone" TEXT NOT NULL, + "location" TEXT, + "ownerId" TEXT NOT NULL, + "status" "AppointmentStatus" NOT NULL DEFAULT 'SCHEDULED', + "statusChangedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "version" INTEGER NOT NULL DEFAULT 1, + CONSTRAINT "appointmentDetails_pkey" PRIMARY KEY ("activityId"), + CONSTRAINT "appointmentDetails_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "activity"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "appointmentDetails_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "appointmentDetails_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +CREATE INDEX "appointmentDetails_organizationId_startsAt_activityId_idx" ON "appointmentDetails"("organizationId", "startsAt", "activityId"); +CREATE POLICY tenant_isolation ON "appointmentDetails" + USING ("organizationId" = current_setting('app.current_organization_id'::text, true)) + WITH CHECK ("organizationId" = current_setting('app.current_organization_id'::text, true)); +ALTER TABLE "appointmentDetails" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "appointmentDetails" FORCE ROW LEVEL SECURITY; + +ALTER TABLE "artifact" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1; +ALTER TABLE "artifact" ADD COLUMN "updatedAt" TIMESTAMP(3); +DO $$ +DECLARE tenant_id TEXT; +BEGIN + FOR tenant_id IN SELECT "id" FROM "organization" LOOP + PERFORM set_config('app.current_organization_id', tenant_id, true); + UPDATE "artifact" SET "updatedAt" = "createdAt"; + END LOOP; +END $$; +ALTER TABLE "artifact" ALTER COLUMN "updatedAt" SET NOT NULL; +ALTER TABLE "artifact" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP; +CREATE INDEX "artifact_dealId_activityId_status_createdAt_id_idx" ON "artifact"("dealId", "activityId", "status", "createdAt", "id"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 82bc1f368..bcf7662c9 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -18,6 +18,12 @@ model User { email String emailVerified Boolean @default(false) image String? + locale String? + role String? + isAnonymous Boolean @default(false) + banned Boolean @default(false) + banReason String? + banExpires DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt sessions Session[] @@ -27,6 +33,7 @@ model User { ownedContacts Contact[] @relation("ContactOwner") ownedDeals Deal[] @relation("DealOwner") activities Activity[] @relation("ActivityAuthor") + ownedAppointments AppointmentDetails[] @relation("AppointmentOwner") mailboxSyncs MailboxSync[] factDecisions ContactFact[] @relation("FactDecider") conversations AgentConversation[] @relation("ConversationOwner") @@ -48,28 +55,52 @@ model User { ssoproviders SsoProvider[] - apiKeys Apikey[] + pushTokens PushToken[] + + oauthclients OauthClient[] + oauthrefreshtokens OauthRefreshToken[] + oauthaccesstokens OauthAccessToken[] + oauthconsents OauthConsent[] @@unique([email]) @@map("user") } +model PushToken { + id String @id @default(cuid()) + userId String @map("user_id") + token String + platform String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([token], map: "push_token_token_unique") + @@index([userId], map: "push_token_userId_idx") + @@map("push_token") +} + model SlackMemberMatch { - id String @id @default(cuid()) - crmUserId String @unique - crmUser User @relation(fields: [crmUserId], references: [id], onDelete: Cascade) - slackUserId String? - slackHandle String? - slackEmail String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + crmUserId String @unique + crmUser User @relation(fields: [crmUserId], references: [id], onDelete: Cascade) + slackUserId String? + slackHandle String? + slackEmail String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@index([organizationId]) @@index([slackUserId]) @@map("slackMemberMatch") } model SlackChannel { id String @id + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) name String memberCount Int? available Boolean @default(true) @@ -80,31 +111,42 @@ model SlackChannel { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@index([organizationId]) @@index([available, name]) @@index([updatedAt]) @@map("slackChannel") } model SlackInstallation { - installerId String @id + installerId String + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) teamId String teamName String? + botToken String? + botScopes String @default("") userToken String? userScopes String createdAt DateTime @default(now()) + @@id([organizationId, installerId]) @@map("slackInstallation") } model SlackWorkspaceGrant { id String @id @default(cuid()) - teamId String @unique + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + teamId String teamName String? + botToken String? + botScopes String @default("") userToken String userScopes String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@unique([organizationId, teamId]) @@map("slackWorkspaceGrant") } @@ -120,6 +162,10 @@ model Session { user User @relation(fields: [userId], references: [id], onDelete: Cascade) activeOrganizationId String? + activeTeamId String? + impersonatedBy String? + oauthrefreshtokens OauthRefreshToken[] + oauthaccesstokens OauthAccessToken[] @@unique([token]) @@index([userId]) @@ -128,6 +174,7 @@ model Session { model Account { id String @id + issuer String accountId String providerId String userId String @@ -142,6 +189,8 @@ model Account { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@unique([issuer, accountId], map: "account_issuer_accountId_uidx") + @@unique([userId, providerId], map: "account_userId_providerId_uidx") @@index([userId]) @@map("account") } @@ -168,6 +217,189 @@ model RateLimit { @@map("rateLimit") } +model Jwks { + id String @id + publicKey String + privateKey String + createdAt DateTime + expiresAt DateTime? + alg String? + crv String? + + @@map("jwks") +} + +model OauthClient { + id String @id + clientId String + clientSecret String? + clientDiscoveryId String? + disabled Boolean? @default(false) + skipConsent Boolean? + enableEndSession Boolean? + subjectType String? + scopes String[] + clientCredentialsScopes String[] @default([]) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime? + updatedAt DateTime? + name String? + uri String? + icon String? + contacts String[] + tos String? + policy String? + softwareId String? + softwareVersion String? + softwareStatement String? + redirectUris String[] + postLogoutRedirectUris String[] + backchannelLogoutUri String? + backchannelLogoutSessionRequired Boolean? + tokenEndpointAuthMethod String? + applicationType String? + jwks String? + jwksUri String? + grantTypes String[] + responseTypes String[] + requirePKCE Boolean? + dpopBoundAccessTokens Boolean? @default(false) + referenceId String? + metadata Json? + oauthclientresources OauthClientResource[] + oauthrefreshtokens OauthRefreshToken[] + oauthaccesstokens OauthAccessToken[] + oauthconsents OauthConsent[] + + @@unique([clientId]) + @@index([userId]) + @@map("oauthClient") +} + +model OauthResource { + id String @id + identifier String + name String + accessTokenTtl Int? + refreshTokenTtl Int? + signingAlgorithm String? + signingKeyId String? + allowedScopes String[] + customClaims Json? + dpopBoundAccessTokensRequired Boolean? @default(false) + disabled Boolean? @default(false) + createdAt DateTime? + updatedAt DateTime? + policyVersion Int? @default(1) + metadata Json? + oauthclientresources OauthClientResource[] + + @@unique([identifier]) + @@map("oauthResource") +} + +model OauthClientResource { + id String @id + clientId String + oauthclient OauthClient @relation(fields: [clientId], references: [clientId], onDelete: Cascade) + resourceId String + oauthresource OauthResource @relation(fields: [resourceId], references: [identifier], onDelete: Cascade) + metadata Json? + createdAt DateTime? + + @@index([clientId]) + @@index([resourceId]) + @@map("oauthClientResource") +} + +model OauthRefreshToken { + id String @id + token String + clientId String + oauthclient OauthClient @relation(fields: [clientId], references: [clientId], onDelete: Cascade) + sessionId String? + session Session? @relation(fields: [sessionId], references: [id], onDelete: SetNull) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + referenceId String? + authorizationCodeId String? + resources String[] @default([]) + requestedUserInfoClaims String[] @default([]) + expiresAt DateTime? + createdAt DateTime? + revoked DateTime? + rotatedAt DateTime? + rotationReplayResponse String? + rotationReplayExpiresAt DateTime? + authTime DateTime? + confirmation Json? + scopes String[] + oauthaccesstokens OauthAccessToken[] + + @@unique([token]) + @@index([clientId]) + @@index([sessionId]) + @@index([userId]) + @@index([authorizationCodeId]) + @@map("oauthRefreshToken") +} + +model OauthAccessToken { + id String @id + token String? + clientId String + oauthclient OauthClient @relation(fields: [clientId], references: [clientId], onDelete: Cascade) + sessionId String? + session Session? @relation(fields: [sessionId], references: [id], onDelete: SetNull) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + referenceId String? + authorizationCodeId String? + resources String[] @default([]) + requestedUserInfoClaims String[] @default([]) + refreshId String? + oauthrefreshtoken OauthRefreshToken? @relation(fields: [refreshId], references: [id], onDelete: Cascade) + expiresAt DateTime? + createdAt DateTime? + revoked DateTime? + confirmation Json? + scopes String[] + + @@unique([token]) + @@index([clientId]) + @@index([sessionId]) + @@index([userId]) + @@index([authorizationCodeId]) + @@index([refreshId]) + @@map("oauthAccessToken") +} + +model OauthConsent { + id String @id + clientId String + oauthclient OauthClient @relation(fields: [clientId], references: [clientId], onDelete: Cascade) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + referenceId String? + resources String[] @default([]) + requestedUserInfoClaims String[] @default([]) + scopes String[] + createdAt DateTime? + updatedAt DateTime? + + @@index([clientId]) + @@index([userId]) + @@map("oauthConsent") +} + +model OauthClientAssertion { + id String @id + expiresAt DateTime + + @@map("oauthClientAssertion") +} + enum DealStage { DEMO_BOOKED QUALIFIED_TO_BUY @@ -209,6 +441,15 @@ enum AgentConversationKind { BUILDER } +enum XmppAgentTaskState { + ACCEPTED + RUNNING + CANCELLING + COMPLETED + FAILED + CANCELLED +} + enum AgentDefinitionStatus { DRAFT DEPLOYING @@ -279,6 +520,8 @@ enum AgentResponseRating { model Company { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) name String domain String? website String? @@ -331,7 +574,8 @@ model Company { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([domain], map: "company_domain_active_key", where: { archivedAt: null }) + @@unique([organizationId, domain], map: "company_organization_domain_active_key", where: { archivedAt: null }) + @@index([organizationId]) @@index([ownerId]) @@index([name]) @@index([lastActivityAt]) @@ -342,15 +586,20 @@ model Company { model CompanyEnrichment { companyId String @id company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) source String @default("context.dev") raw Json fetchedAt DateTime @default(now()) + @@index([organizationId]) @@map("companyEnrichment") } model Contact { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) firstName String lastName String? email String? @@ -395,7 +644,8 @@ model Contact { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([email], map: "contact_email_active_key", where: { archivedAt: null }) + @@unique([organizationId, email], map: "contact_organization_email_active_key", where: { archivedAt: null }) + @@index([organizationId]) @@index([companyId]) @@index([ownerId]) @@index([lastActivityAt]) @@ -420,6 +670,8 @@ model ContactFact { id String @id @default(cuid()) contactId String contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) field String value String @@ -442,6 +694,7 @@ model ContactFact { observedAt DateTime @default(now()) supersededAt DateTime? + @@index([organizationId]) @@index([contactId, field, status]) @@index([status, observedAt]) @@map("contactFact") @@ -450,6 +703,8 @@ model ContactFact { model ContactBrief { contactId String @id contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) narrative String sections Json @@ -460,11 +715,14 @@ model ContactBrief { refreshedAt DateTime @default(now()) + @@index([organizationId]) @@map("contactBrief") } model AgentTask { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) contactId String? companyId String? dealId String? @@ -489,6 +747,7 @@ model AgentTask { createdAt DateTime @default(now()) + @@index([organizationId]) @@index([dueAt, leasedUntil]) @@index([contactId]) @@index([dealId]) @@ -496,8 +755,44 @@ model AgentTask { @@map("agentTask") } +model XmppAgentTask { + id String @id + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + requestId String + callerJid String + notificationJid String + targetJid String + tool String + apiVersion String + manifestHash String + fingerprint String + arguments Json + state XmppAgentTaskState @default(ACCEPTED) + revision Int @default(0) + progress Json? + result Json? + error Json? + summary String? + eveSessionId String? + ownerId String? + leaseUntil DateTime? + deadline DateTime? + retainUntil DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, callerJid, targetJid, requestId]) + @@index([organizationId, state, updatedAt]) + @@index([organizationId, state, leaseUntil]) + @@index([retainUntil]) + @@map("xmppAgentTask") +} + model AgentEvent { id String @id + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) sessionId String contactId String? conversationId String? @@ -507,6 +802,7 @@ model AgentEvent { emittedAt DateTime + @@index([organizationId]) @@index([sessionId, emittedAt]) @@index([contactId, emittedAt]) @@index([conversationId, emittedAt]) @@ -515,6 +811,8 @@ model AgentEvent { model AgentConversation { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) kind AgentConversationKind @default(RECORD) @@ -552,6 +850,7 @@ model AgentConversation { createdVersions AgentVersion[] @relation("AgentVersionSourceConversation") builderArtifacts AgentBuilderArtifact[] + @@index([organizationId]) @@index([contactId, lastMessageAt]) @@index([companyId, lastMessageAt]) @@index([dealId, lastMessageAt]) @@ -562,6 +861,8 @@ model AgentConversation { model AgentConversationFeedback { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) conversationId String conversation AgentConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) @@ -576,12 +877,15 @@ model AgentConversationFeedback { updatedAt DateTime @updatedAt @@unique([conversationId, userId, messageId]) + @@index([organizationId]) @@index([conversationId, createdAt]) @@map("agentConversationFeedback") } model AgentConversationShare { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) conversationId String conversation AgentConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) @@ -597,12 +901,15 @@ model AgentConversationShare { revokedAt DateTime? @@unique([conversationId], map: "agentConversationShare_one_active_per_conversation", where: { revokedAt: null }) + @@index([organizationId]) @@index([conversationId, revokedAt]) @@map("agentConversationShare") } model AgentConversationSubmission { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) conversationId String conversation AgentConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) @@ -627,6 +934,7 @@ model AgentConversationSubmission { attachments AgentConversationAttachment[] @@unique([conversationId, inputRequestId]) + @@index([organizationId]) @@index([conversationId, createdAt]) @@index([status, createdAt]) @@map("agentConversationSubmission") @@ -634,6 +942,8 @@ model AgentConversationSubmission { model AgentConversationAttachment { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) submissionId String submission AgentConversationSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade) @@ -646,12 +956,15 @@ model AgentConversationAttachment { createdAt DateTime @default(now()) + @@index([organizationId]) @@index([submissionId, position]) @@map("agentConversationAttachment") } model AgentDefinition { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) name String description String? @@ -676,6 +989,7 @@ model AgentDefinition { deletedAt DateTime? @@unique([currentVersionId, id]) + @@index([organizationId]) @@index([status, updatedAt]) @@index([createdById, createdAt]) @@map("agentDefinition") @@ -683,6 +997,8 @@ model AgentDefinition { model AgentVersion { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) agentId String agent AgentDefinition @relation("AgentVersions", fields: [agentId], references: [id], onDelete: Restrict) @@ -715,6 +1031,7 @@ model AgentVersion { builderArtifacts AgentBuilderArtifact[] @@unique([agentId, number]) + @@index([organizationId]) @@unique([id, agentId]) @@index([agentId, createdAt]) @@index([status, createdAt]) @@ -723,6 +1040,8 @@ model AgentVersion { model AgentBuilderArtifact { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) conversationId String? conversation AgentConversation? @relation(fields: [conversationId], references: [id], onDelete: SetNull) @@ -739,6 +1058,7 @@ model AgentBuilderArtifact { createdAt DateTime @default(now()) @@unique([conversationId, path, revision], map: "agentBuilderArtifact_conversation_path_revision_key", where: { conversationId: { not: null } }) + @@index([organizationId]) @@unique([versionId, path, revision], map: "agentBuilderArtifact_version_path_revision_key", where: { versionId: { not: null } }) @@index([conversationId, createdAt]) @@index([versionId, path]) @@ -747,6 +1067,8 @@ model AgentBuilderArtifact { model AgentTrigger { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) agentId String agent AgentDefinition @relation(fields: [agentId], references: [id], onDelete: Restrict) @@ -770,6 +1092,7 @@ model AgentTrigger { runs AgentRun[] @@unique([id, agentId]) + @@index([organizationId]) @@index([agentId, enabled]) @@index([enabled, nextRunAt]) @@index([versionId]) @@ -778,6 +1101,8 @@ model AgentTrigger { model AgentRun { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) agentId String agent AgentDefinition @relation(fields: [agentId], references: [id], onDelete: Restrict) @@ -791,6 +1116,9 @@ model AgentRun { initiatedById String? initiatedBy User? @relation("AgentRunInitiator", fields: [initiatedById], references: [id], onDelete: SetNull) + dealId String? + deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) + triggerType AgentTriggerType status AgentRunStatus @default(QUEUED) principalId String? @@ -823,15 +1151,19 @@ model AgentRun { actions AgentAction[] @@unique([id, agentId]) + @@index([organizationId]) @@index([agentId, createdAt]) @@index([versionId, createdAt]) @@index([status, createdAt]) @@index([triggerId, createdAt]) + @@index([dealId, createdAt]) @@map("agentRun") } model AgentRunEvent { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) runId String run AgentRun @relation(fields: [runId], references: [id], onDelete: Restrict) @@ -842,12 +1174,15 @@ model AgentRunEvent { emittedAt DateTime @default(now()) @@unique([runId, sequence]) + @@index([organizationId]) @@index([runId, emittedAt]) @@map("agentRunEvent") } model AgentAction { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) agentId String agent AgentDefinition @relation(fields: [agentId], references: [id], onDelete: Restrict) @@ -877,6 +1212,7 @@ model AgentAction { completedAt DateTime? updatedAt DateTime @updatedAt + @@index([organizationId]) @@index([agentId, plannedAt]) @@index([runId, plannedAt]) @@index([provider, externalId]) @@ -886,6 +1222,8 @@ model AgentAction { model AgentAuditEvent { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) agentId String agent AgentDefinition @relation(fields: [agentId], references: [id], onDelete: Restrict) @@ -906,6 +1244,7 @@ model AgentAuditEvent { emittedAt DateTime @default(now()) @@unique([agentId, type, requestId]) + @@index([organizationId]) @@index([agentId, emittedAt]) @@index([versionId, emittedAt]) @@index([actorUserId, emittedAt]) @@ -915,7 +1254,10 @@ model AgentAuditEvent { model Deal { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) conversations AgentConversation[] + agentRuns AgentRun[] name String description String? companyId String @@ -936,15 +1278,26 @@ model Deal { fxRate Decimal? @db.Decimal(20, 10) fxRateAt DateTime? + leadSource String? + projectType String? + addressLine1 String? + addressLine2 String? + city String? + state String? + postalCode String? + lastActivityAt DateTime? archivedAt DateTime? contacts DealContact[] activities Activity[] fieldValues FieldValue[] + artifacts Artifact[] + documents Document[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@index([organizationId]) @@index([companyId]) @@index([ownerId]) @@index([stage]) @@ -980,15 +1333,236 @@ model ExchangeRate { model DealContact { dealId String deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) contactId String contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) role String? @@id([dealId, contactId]) + @@index([organizationId]) @@index([contactId]) @@map("dealContact") } +enum AssetSource { + MANUAL + MOBILE_RECORDING + EMAIL_ATTACHMENT +} + +enum AssetStatus { + UNVERIFIED + READY + DELETING + DELETED +} + +enum AssetUploadStatus { + PENDING + FINALIZING + READY + FAILED + CANCELED + EXPIRED +} + +enum AssetJobOperation { + FINALIZE_UPLOAD + DELETE_OBJECT +} + +enum AssetJobState { + PENDING + RUNNING + COMPLETE +} + +model Artifact { + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + dealId String + deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade) + type String + fileName String + storageKey String + storageBucket String? + kind String @default("file") + contentType String @default("application/octet-stream") + sizeBytes BigInt? + source AssetSource? + activityId String? + uploadedById String? + durationMilliseconds BigInt? + capturedAt DateTime? + emailMessageId String? + emailAttachmentId String? + status AssetStatus @default(UNVERIFIED) + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + version Int @default(1) + + @@unique([storageBucket, storageKey]) + @@index([dealId, createdAt]) + @@index([dealId, status, createdAt, id]) + @@index([dealId, activityId, status, createdAt, id]) + @@index([organizationId]) + @@map("artifact") +} + +model AssetUpload { + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + projectId String + customerId String + actorKey String + uploadedById String? + mailboxOwnerId String? + fileName String + contentType String + sizeBytes BigInt + kind String + source AssetSource + activityId String? + durationMilliseconds BigInt? + capturedAt DateTime? + emailMessageId String? + emailAttachmentId String? + metadataHash String + bucket String + temporaryKey String @unique + finalKey String @unique + sourceEtag String? + status AssetUploadStatus @default(PENDING) + assetId String? @unique + failureCode String? + failureMessage String? + expiresAt DateTime + grantExpiresAt DateTime + reservationUntil DateTime + reservationReleasedAt DateTime? + confirmedAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([actorKey, reservationReleasedAt]) + @@index([projectId]) + @@index([status, expiresAt]) + @@index([organizationId]) + @@map("assetUpload") +} + +model AssetEmailSource { + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + messageId String + attachmentId String + projectId String + metadataHash String + uploadId String + assetId String? + deletedAt DateTime? + mailboxOwnerId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([messageId, attachmentId]) + @@index([projectId]) + @@index([organizationId]) + @@map("assetEmailSource") +} + +model AssetStorageJob { + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + operationKey String @unique + operation AssetJobOperation + projectId String + uploadId String? + artifactId String? + bucket String? + objectKey String + finalKey String? + temporary Boolean @default(false) + state AssetJobState @default(PENDING) + attempts Int @default(0) + nextAttemptAt DateTime @default(now()) + leaseUntil DateTime? + leaseToken String? + lastError String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([state, nextAttemptAt, leaseUntil]) + @@index([projectId]) + @@index([organizationId]) + @@map("assetStorageJob") +} + +model AssetApiRequest { + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + actorKey String + operation String + path String + idempotencyKey String + requestHash String + responseStatus Int + responseBody Json + expiresAt DateTime + createdAt DateTime @default(now()) + + @@unique([actorKey, operation, path, idempotencyKey]) + @@index([expiresAt]) + @@index([organizationId]) + @@map("assetApiRequest") +} + +model Document { + id String @id @default(cuid()) + dealId String + deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade) + type String + number String + status String + currency String @default("USD") + issuedAt DateTime? + dueAt DateTime? + recipientSnapshot Json + contractorSnapshot Json + projectSnapshot Json + subtotal Decimal @db.Decimal(14, 2) + tax Decimal @db.Decimal(14, 2) + total Decimal @db.Decimal(14, 2) + lineItems DocumentLineItem[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([dealId, createdAt]) + @@map("document") +} + +model DocumentLineItem { + id String @id @default(cuid()) + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + description String + quantity Decimal @db.Decimal(14, 2) + unitPrice Decimal @db.Decimal(14, 2) + total Decimal @db.Decimal(14, 2) + position Int + + @@index([documentId, position]) + @@map("documentLineItem") +} + enum FieldEntity { COMPANY CONTACT @@ -1010,6 +1584,8 @@ enum FieldType { model FieldDefinition { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) entity FieldEntity key String label String @@ -1032,13 +1608,16 @@ model FieldDefinition { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([entity, key]) + @@unique([organizationId, entity, key]) + @@index([organizationId]) @@index([entity, position]) @@map("fieldDefinition") } model FieldOption { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) fieldId String field FieldDefinition @relation(fields: [fieldId], references: [id], onDelete: Cascade) label String @@ -1047,12 +1626,15 @@ model FieldOption { archivedAt DateTime? values FieldValue[] + @@index([organizationId]) @@index([fieldId, position]) @@map("fieldOption") } model FieldValue { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) fieldId String field FieldDefinition @relation(fields: [fieldId], references: [id], onDelete: Cascade) @@ -1074,6 +1656,7 @@ model FieldValue { updatedAt DateTime @updatedAt + @@index([organizationId]) @@unique([fieldId, companyId]) @@unique([fieldId, contactId]) @@unique([fieldId, dealId]) @@ -1089,11 +1672,13 @@ model FieldValue { } model SavedView { - id String @id @default(cuid()) - entity FieldEntity - name String - shared Boolean @default(false) - filters Json + id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + entity FieldEntity + name String + shared Boolean @default(false) + filters Json ownerId String owner User @relation("SavedViewOwner", fields: [ownerId], references: [id], onDelete: Cascade) @@ -1101,13 +1686,16 @@ model SavedView { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([entity, ownerId, name]) + @@unique([organizationId, entity, ownerId, name]) + @@index([organizationId]) @@index([entity, shared]) @@map("savedView") } model Activity { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) type ActivityType subject String? body String? @@ -1115,6 +1703,8 @@ model Activity { occurredAt DateTime? dueAt DateTime? completedAt DateTime? + archivedAt DateTime? + appointmentDetails AppointmentDetails? companyId String? company Company? @relation(fields: [companyId], references: [id], onDelete: Cascade) @@ -1135,14 +1725,41 @@ model Activity { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@index([organizationId]) @@index([companyId, createdAt]) @@index([dealId, createdAt]) @@index([contactId, createdAt]) @@index([dueAt]) + @@index([archivedAt]) @@index([createdById]) @@map("activity") } +enum AppointmentStatus { + SCHEDULED + COMPLETED + CANCELED +} + +model AppointmentDetails { + activityId String @id + activity Activity @relation(fields: [activityId], references: [id], onDelete: Cascade) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + startsAt DateTime + endsAt DateTime? + timeZone String + location String? + ownerId String + owner User @relation("AppointmentOwner", fields: [ownerId], references: [id]) + status AppointmentStatus @default(SCHEDULED) + statusChangedAt DateTime @default(now()) + version Int @default(1) + + @@index([organizationId, startsAt, activityId]) + @@map("appointmentDetails") +} + enum GoogleSyncStatus { IDLE RUNNING @@ -1157,6 +1774,8 @@ enum EmailDirection { model MailboxSync { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) source String @@ -1171,13 +1790,16 @@ model MailboxSync { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([userId, source]) + @@index([organizationId]) + @@unique([organizationId, userId, source]) @@index([status]) @@map("mailboxSync") } model EmailThread { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) rootMessageId String @unique subject String? @@ -1196,6 +1818,7 @@ model EmailThread { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@index([organizationId]) @@index([companyId, lastMessageAt]) @@index([contactId, lastMessageAt]) @@map("emailThread") @@ -1203,6 +1826,8 @@ model EmailThread { model EmailMessage { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) threadId String thread EmailThread @relation(fields: [threadId], references: [id], onDelete: Cascade) @@ -1223,12 +1848,15 @@ model EmailMessage { createdAt DateTime @default(now()) + @@index([organizationId]) @@index([threadId, sentAt]) @@map("emailMessage") } model CalendarEvent { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) iCalUid String originalStartTime DateTime recurringEventId String? @@ -1257,6 +1885,7 @@ model CalendarEvent { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@index([organizationId]) @@unique([iCalUid, originalStartTime]) @@index([companyId, startsAt]) @@index([contactId, startsAt]) @@ -1265,6 +1894,8 @@ model CalendarEvent { model CalendarAttendee { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) eventId String event CalendarEvent @relation(fields: [eventId], references: [id], onDelete: Cascade) @@ -1275,29 +1906,37 @@ model CalendarAttendee { contactId String? contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull) + @@index([organizationId]) @@unique([eventId, email]) @@index([contactId]) @@map("calendarAttendee") } model SuppressedDomain { - domain String @id + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + domain String reason String? createdAt DateTime @default(now()) + @@id([organizationId, domain]) @@map("suppressedDomain") } model SuppressedContact { - email String @id + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + email String reason String? createdAt DateTime @default(now()) + @@id([organizationId, email]) @@map("suppressedContact") } model AppSetting { - id String @id + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) @id + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) agentModelId String? @@ -1326,6 +1965,14 @@ model AppSetting { @@map("appSetting") } +model TrackingSiteLocator { + siteId String @id + organizationId String @unique + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@map("trackingSiteLocator") +} + enum DomainScope { SITE_AND_SUBDOMAINS EXACT_HOST @@ -1333,7 +1980,9 @@ enum DomainScope { model TrackedDomain { id String @id @default(cuid()) - host String @unique + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + host String scope DomainScope @default(EXACT_HOST) pageViews Int @default(0) @@ -1341,11 +1990,15 @@ model TrackedDomain { createdAt DateTime @default(now()) + @@unique([organizationId, host]) + @@index([organizationId]) @@map("trackedDomain") } model TrackedVisitor { id String @id + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) contactId String? contact Contact? @relation("VisitorContact", fields: [contactId], references: [id], onDelete: SetNull) @@ -1371,6 +2024,7 @@ model TrackedVisitor { firstSeen DateTime @default(now()) lastSeen DateTime @updatedAt + @@index([organizationId]) @@index([contactId]) @@index([firstSource]) @@map("trackedVisitor") @@ -1378,6 +2032,8 @@ model TrackedVisitor { model TrackedEvent { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) visitorId String type String @@ -1392,6 +2048,7 @@ model TrackedEvent { occurredAt DateTime + @@index([organizationId]) @@index([visitorId, occurredAt]) @@index([occurredAt]) @@index([host, occurredAt]) @@ -1400,28 +2057,35 @@ model TrackedEvent { } model TrackingCounter { - key String @id + key String + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) value Int @default(0) expiresAt DateTime + @@id([organizationId, key]) @@index([expiresAt]) @@map("trackingCounter") } model TrackedPageDaily { + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) day DateTime host String path String views Int @default(0) visitors Int @default(0) - @@id([day, host, path]) + @@id([organizationId, day, host, path]) @@index([host, day]) @@map("trackedPageDaily") } model FormSubmission { id String @id @default(cuid()) + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) visitorId String? contactId String? @@ -1442,6 +2106,7 @@ model FormSubmission { createdAt DateTime @default(now()) + @@index([organizationId]) @@index([contactId]) @@index([createdAt]) @@map("formSubmission") @@ -1477,22 +2142,81 @@ model TelemetryCounter { } model Organization { - id String @id - name String - slug String - logo String? - createdAt DateTime - metadata String? - website String? - members Member[] - invitations Invitation[] + id String @id + name String + slug String + logo String? + createdAt DateTime + metadata String? + website String? + members Member[] + invitations Invitation[] + xmppAgentTasks XmppAgentTask[] + + slackMemberMatches SlackMemberMatch[] + slackChannels SlackChannel[] + slackInstallations SlackInstallation[] + slackWorkspaceGrants SlackWorkspaceGrant[] + companies Company[] + companyEnrichments CompanyEnrichment[] + contacts Contact[] + contactFacts ContactFact[] + contactBriefs ContactBrief[] + agentTasks AgentTask[] + agentEvents AgentEvent[] + agentConversations AgentConversation[] + agentConversationFeedback AgentConversationFeedback[] + agentConversationShares AgentConversationShare[] + agentConversationSubmissions AgentConversationSubmission[] + agentConversationAttachments AgentConversationAttachment[] + agentDefinitions AgentDefinition[] + agentVersions AgentVersion[] + agentBuilderArtifacts AgentBuilderArtifact[] + agentTriggers AgentTrigger[] + agentRuns AgentRun[] + agentRunEvents AgentRunEvent[] + agentActions AgentAction[] + agentAuditEvents AgentAuditEvent[] + artifacts Artifact[] + assetUploads AssetUpload[] + assetEmailSources AssetEmailSource[] + assetStorageJobs AssetStorageJob[] + assetApiRequests AssetApiRequest[] + deals Deal[] + dealContacts DealContact[] + fieldDefinitions FieldDefinition[] + fieldOptions FieldOption[] + fieldValues FieldValue[] + savedViews SavedView[] + activities Activity[] + appointmentDetails AppointmentDetails[] + mailboxSyncs MailboxSync[] + emailThreads EmailThread[] + emailMessages EmailMessage[] + calendarEvents CalendarEvent[] + calendarAttendees CalendarAttendee[] + suppressedDomains SuppressedDomain[] + suppressedContacts SuppressedContact[] + appSetting AppSetting? + trackingSiteLocator TrackingSiteLocator? + trackedDomains TrackedDomain[] + trackedVisitors TrackedVisitor[] + trackedEvents TrackedEvent[] + trackingCounters TrackingCounter[] + trackedPageDailies TrackedPageDaily[] + formSubmissions FormSubmission[] + workspaceProfile WorkspaceProfile? + ssoProviders SsoProvider[] + ssoProviderLocators SsoProviderLocator[] + apiKeys Apikey[] @@unique([slug]) @@map("organization") } model WorkspaceProfile { - id String @id + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) @id + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) website String narrative String @@ -1546,20 +2270,34 @@ model SsoProvider { userId String? user User? @relation(fields: [userId], references: [id], onDelete: Cascade) providerId String - organizationId String? + organizationId String @default(dbgenerated("current_setting('app.current_organization_id'::text, true)")) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) domain String + locator SsoProviderLocator? @@unique([providerId]) + @@index([organizationId]) @@map("ssoProvider") } +model SsoProviderLocator { + providerId String @id + organizationId String + domain String + provider SsoProvider @relation(fields: [providerId], references: [providerId], onDelete: Cascade, onUpdate: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@index([organizationId]) + @@map("ssoProviderLocator") +} + model Apikey { id String @id configId String @default("default") name String? start String? referenceId String - user User @relation(fields: [referenceId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [referenceId], references: [id], onDelete: Cascade) prefix String? key String refillInterval Int? @@ -1583,3 +2321,524 @@ model Apikey { @@index([key]) @@map("apikey") } +model UserAvatar { + id String @id @default(cuid()) + userId String @map("user_id") @unique(map: "user_avatar_user_id_unique") + mimeType String @map("mime_type") + size Int + data Bytes + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@index([userId], map: "user_avatar_userId_idx") + @@map("user_avatar") +} + +model Workspace { + id String @id @default(cuid()) + name String + slug String @unique + logo String? + metadata String? + description String? + createdAt DateTime @map("created_at") + workspaceMembers WorkspaceMember[] + workspaceBillings WorkspaceBilling[] + teams Team[] + workspaceInvitations WorkspaceInvitation[] + workspaceRoles WorkspaceRole[] + projects Project[] + billingReminderSents BillingReminderSent[] + assets Asset[] + labels Label[] + userNotificationWorkspaceRules UserNotificationWorkspaceRule[] + userNotificationWorkspaceProjects UserNotificationWorkspaceProject[] + @@map("workspace") +} + +model WorkspaceMember { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + userId String @map("user_id") + role String @default("member") + joinedAt DateTime @map("joined_at") + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + @@index([workspaceId], map: "workspace_member_workspaceId_idx") + @@index([userId], map: "workspace_member_userId_idx") + @@map("workspace_member") +} + +model WorkspaceBilling { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") @unique(map: "workspace_billing_workspace_id_unique") + foundingFree Boolean @map("founding_free") @default(false) + trialEndsAt DateTime? @map("trial_ends_at") + creemCustomerId String? @map("creem_customer_id") + creemSubscriptionId String? @map("creem_subscription_id") @unique + creemProductId String? @map("creem_product_id") + plan String? + billingInterval String? @map("billing_interval") + status String? + seats Int @default(1) + currentPeriodEnd DateTime? @map("current_period_end") + canceledAt DateTime? @map("canceled_at") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([workspaceId], map: "workspace_billing_workspaceId_idx") + @@map("workspace_billing") +} + +model TrialGrant { + emailHash String @map("email_hash") @id + trialEndsAt DateTime @map("trial_ends_at") + createdAt DateTime @map("created_at") @default(now()) + @@map("trial_grant") +} + +model BillingEvent { + id String @id + eventType String @map("event_type") + processedAt DateTime @map("processed_at") @default(now()) + @@map("billing_event") +} + +model Team { + id String @id + name String + workspaceId String @map("workspace_id") + createdAt DateTime @map("created_at") + updatedAt DateTime? @map("updated_at") @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + teamMembers TeamMember[] + @@index([workspaceId], map: "team_workspaceId_idx") + @@map("team") +} + +model TeamMember { + id String @id + teamId String @map("team_id") + userId String @map("user_id") + createdAt DateTime? @map("created_at") + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + @@index([teamId], map: "teamMember_teamId_idx") + @@index([userId], map: "teamMember_userId_idx") + @@map("team_member") +} + +model WorkspaceInvitation { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + email String + role String? + teamId String? @map("team_id") + status String @default("pending") + expiresAt DateTime @map("expires_at") + createdAt DateTime @map("created_at") @default(now()) + inviterId String @map("inviter_id") + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + @@index([workspaceId], map: "workspace_invitation_workspaceId_idx") + @@index([email], map: "workspace_invitation_email_idx") + @@index([inviterId], map: "workspace_invitation_inviterId_idx") + @@map("workspace_invitation") +} + +model WorkspaceRole { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + role String + permission String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([workspaceId], map: "workspace_role_workspaceId_idx") + @@index([role], map: "workspace_role_role_idx") + @@map("workspace_role") +} + +model Project { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + slug String + icon String? @default("Layout") + name String + description String? + createdAt DateTime @map("created_at") @default(now()) + isPublic Boolean? @map("is_public") @default(false) + archivedAt DateTime? @map("archived_at") + lastTaskNumber Int @map("last_task_number") @default(0) + position Int @default(0) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectColumns ProjectColumn[] + workflowRules WorkflowRule[] + projectTasks ProjectTask[] + assets Asset[] + userNotificationWorkspaceProjects UserNotificationWorkspaceProject[] + githubIntegrations GithubIntegration[] + integrations Integration[] + @@unique([workspaceId, id], map: "project_workspace_id_id_unique") + @@index([workspaceId, position], map: "project_workspaceId_position_idx") + @@map("project") +} + +model ProjectColumn { + id String @id @default(cuid()) + projectId String @map("project_id") + name String + slug String + position Int @default(0) + icon String? + color String? + isFinal Boolean @map("is_final") @default(false) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + workflowRules WorkflowRule[] + projectTasks ProjectTask[] + @@index([projectId], map: "column_projectId_idx") + @@map("column") +} + +model WorkflowRule { + id String @id @default(cuid()) + projectId String @map("project_id") + integrationType String @map("integration_type") + eventType String @map("event_type") + columnId String @map("column_id") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + column ProjectColumn @relation(fields: [columnId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([projectId], map: "workflow_rule_projectId_idx") + @@index([columnId], map: "workflow_rule_columnId_idx") + @@map("workflow_rule") +} + +model ProjectTask { + id String @id @default(cuid()) + projectId String @map("project_id") + position Int? @default(0) + number Int? @default(1) + userId String? @map("assignee_id") + title String + description String? + status String @default("to-do") + columnId String? @map("column_id") + priority String @default("low") + startDate DateTime? @map("start_date") + dueDate DateTime? @map("due_date") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + column ProjectColumn? @relation(fields: [columnId], references: [id], onDelete: SetNull, onUpdate: Cascade) + taskReminderSents TaskReminderSent[] + timeEntries TimeEntry[] + taskActivities TaskActivity[] + assets Asset[] + labels Label[] + externalLinks ExternalLink[] + taskComments TaskComment[] + taskRelations TaskRelation[] + taskRelations2 TaskRelation[] @relation("TaskRelationToProjectTask_1") + @@unique([projectId, number], map: "task_project_number_unique") + @@index([projectId], map: "task_projectId_idx") + @@index([dueDate], map: "task_dueDate_idx") + @@index([userId], map: "task_assigneeId_idx") + @@index([columnId], map: "task_columnId_idx") + @@map("task") +} + +model BillingReminderSent { + id String @id @default(cuid()) + userId String @map("user_id") + workspaceId String @map("workspace_id") + reminderType String @map("reminder_type") + trialEndsAt DateTime? @map("trial_ends_at") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@unique([userId, reminderType], map: "billing_reminder_sent_user_type_unique") + @@index([workspaceId], map: "billing_reminder_sent_workspaceId_idx") + @@index([userId], map: "billing_reminder_sent_userId_idx") + @@map("billing_reminder_sent") +} + +model JobLease { + name String @id + owner String + expiresAt DateTime @map("expires_at") + @@map("job_lease") +} + +model TaskReminderSent { + id String @id @default(cuid()) + taskId String @map("task_id") + reminderType String @map("reminder_type") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@unique([taskId, reminderType], map: "task_reminder_sent_task_type_unique") + @@index([taskId], map: "task_reminder_sent_taskId_idx") + @@map("task_reminder_sent") +} + +model TimeEntry { + id String @id @default(cuid()) + taskId String @map("task_id") + userId String? @map("user_id") + description String? + startTime DateTime @map("start_time") + endTime DateTime? @map("end_time") + duration Int? @default(0) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([taskId], map: "time_entry_taskId_idx") + @@index([userId], map: "time_entry_userId_idx") + @@map("time_entry") +} + +model TaskActivity { + id String @id @default(cuid()) + taskId String @map("task_id") + type String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + userId String? @map("user_id") + content String? + eventData Json? @map("event_data") + externalUserName String? @map("external_user_name") + externalUserAvatar String? @map("external_user_avatar") + externalSource String? @map("external_source") + externalUrl String? @map("external_url") + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + assets Asset[] + @@unique([taskId, externalSource, externalUrl], map: "activity_task_external_source_external_url_unique") + @@index([taskId], map: "activity_task_id_idx") + @@index([userId], map: "activity_userId_idx") + @@map("task_activity") +} + +model Asset { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + projectId String @map("project_id") + taskId String? @map("task_id") + activityId String? @map("activity_id") + objectKey String @map("object_key") @unique + filename String + mimeType String @map("mime_type") + size Int + kind String @default("image") + surface String @default("description") + createdBy String? @map("created_by") + createdAt DateTime @map("created_at") @default(now()) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + task ProjectTask? @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + activity TaskActivity? @relation(fields: [activityId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([workspaceId], map: "asset_workspaceId_idx") + @@index([projectId], map: "asset_projectId_idx") + @@index([taskId], map: "asset_taskId_idx") + @@index([activityId], map: "asset_activityId_idx") + @@index([createdBy], map: "asset_createdBy_idx") + @@map("asset") +} + +model Label { + id String @id @default(cuid()) + name String + color String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + taskId String? @map("task_id") + workspaceId String? @map("workspace_id") + task ProjectTask? @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@unique([taskId, name], map: "label_task_name_unique") + @@index([taskId], map: "label_task_id_idx") + @@index([workspaceId], map: "label_workspace_id_idx") + @@map("label") +} + +model Notification { + id String @id @default(cuid()) + userId String @map("user_id") + title String? + content String? + type String @default("info") + eventData Json? @map("event_data") + isRead Boolean? @map("is_read") @default(false) + resourceId String? @map("resource_id") + resourceType String? @map("resource_type") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@index([userId], map: "notification_userId_idx") + @@map("notification") +} + +model UserNotificationPreference { + id String @id @default(cuid()) + userId String @map("user_id") @unique + emailEnabled Boolean @map("email_enabled") @default(false) + ntfyEnabled Boolean @map("ntfy_enabled") @default(false) + ntfyServerUrl String? @map("ntfy_server_url") + ntfyTopic String? @map("ntfy_topic") + ntfyToken String? @map("ntfy_token") + gotifyEnabled Boolean @map("gotify_enabled") @default(false) + gotifyServerUrl String? @map("gotify_server_url") + gotifyToken String? @map("gotify_token") + webhookEnabled Boolean @map("webhook_enabled") @default(false) + webhookUrl String? @map("webhook_url") + webhookSecret String? @map("webhook_secret") + taskAssignmentEnabled Boolean @map("task_assignment_enabled") @default(true) + taskCommentEnabled Boolean @map("task_comment_enabled") @default(true) + taskStatusChangeEnabled Boolean @map("task_status_change_enabled") @default(true) + dueDateReminderEnabled Boolean @map("due_date_reminder_enabled") @default(true) + dueDateReminderLeadTimeMinutes Int @map("due_date_reminder_lead_time_minutes") @default(1440) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@map("user_notification_preference") +} + +model UserNotificationWorkspaceRule { + id String @id @default(cuid()) + userId String @map("user_id") + workspaceId String @map("workspace_id") + isActive Boolean @map("is_active") @default(true) + emailEnabled Boolean @map("email_enabled") @default(false) + ntfyEnabled Boolean @map("ntfy_enabled") @default(false) + gotifyEnabled Boolean @map("gotify_enabled") @default(false) + webhookEnabled Boolean @map("webhook_enabled") @default(false) + projectMode String @map("project_mode") @default("all") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userNotificationWorkspaceProjects UserNotificationWorkspaceProject[] + @@unique([userId, workspaceId], map: "user_notification_workspace_rule_user_workspace_unique") + @@unique([workspaceId, id], map: "user_notification_workspace_rule_workspace_id_id_unique") + @@index([userId], map: "user_notification_workspace_rule_userId_idx") + @@index([workspaceId], map: "user_notification_workspace_rule_workspaceId_idx") + @@map("user_notification_workspace_rule") +} + +model UserNotificationWorkspaceProject { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + workspaceRuleId String @map("workspace_rule_id") + projectId String @map("project_id") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userNotificationWorkspaceRule UserNotificationWorkspaceRule @relation(fields: [workspaceId, workspaceRuleId], references: [workspaceId, id], onDelete: Cascade, onUpdate: Cascade) + project Project @relation(fields: [workspaceId, projectId], references: [workspaceId, id], onDelete: Cascade, onUpdate: Cascade) + @@unique([workspaceRuleId, projectId], map: "user_notification_workspace_project_rule_project_unique") + @@index([workspaceRuleId], map: "user_notification_workspace_project_ruleId_idx") + @@index([projectId], map: "user_notification_workspace_project_projectId_idx") + @@index([workspaceId, projectId], map: "user_notification_workspace_project_workspaceId_projectId_idx") + @@index([workspaceId, workspaceRuleId], map: "unwp_workspaceId_workspaceRuleId_idx") + @@map("user_notification_workspace_project") +} + +model GithubIntegration { + id String @id @default(cuid()) + projectId String @map("project_id") @unique + repositoryOwner String @map("repository_owner") + repositoryName String @map("repository_name") + installationId Int? @map("installation_id") + isActive Boolean? @map("is_active") @default(true) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@map("github_integration") +} + +model Integration { + id String @id @default(cuid()) + projectId String @map("project_id") + type String + config String + isActive Boolean? @map("is_active") @default(true) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + externalLinks ExternalLink[] + @@unique([projectId, type], map: "integration_project_type_unique") + @@index([projectId], map: "integration_projectId_idx") + @@index([type], map: "integration_type_idx") + @@map("integration") +} + +model ExternalLink { + id String @id @default(cuid()) + taskId String @map("task_id") + integrationId String @map("integration_id") + resourceType String @map("resource_type") + externalId String @map("external_id") + url String + title String? + metadata String? + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([taskId], map: "external_link_taskId_idx") + @@index([integrationId], map: "external_link_integrationId_idx") + @@index([externalId], map: "external_link_externalId_idx") + @@index([resourceType], map: "external_link_resourceType_idx") + @@map("external_link") +} + +model TaskComment { + id String @id @default(cuid()) + taskId String @map("task_id") + userId String? @map("user_id") + content String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([taskId], map: "comment_task_idx") + @@index([userId], map: "comment_user_idx") + @@map("comment") +} + +model TaskRelation { + id String @id @default(cuid()) + sourceTaskId String @map("source_task_id") + targetTaskId String @map("target_task_id") + relationType String @map("relation_type") + createdAt DateTime @map("created_at") @default(now()) + sourceTask ProjectTask @relation(fields: [sourceTaskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + targetTask ProjectTask @relation("TaskRelationToProjectTask_1", fields: [targetTaskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([sourceTaskId], map: "task_relation_source_idx") + @@index([targetTaskId], map: "task_relation_target_idx") + @@map("task_relation") +} + +model DeviceCode { + id String @id @default(cuid()) + deviceCode String @map("device_code") + userCode String @map("user_code") + userId String? @map("user_id") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + expiresAt DateTime @map("expires_at") + status String + lastPolledAt DateTime? @map("last_polled_at") + pollingInterval Int? @map("polling_interval") + clientId String? @map("client_id") + scope String? + @@index([userId], map: "device_code_user_id_idx") + @@map("device_code") +} + +model McpOauthState { + id String @id @default(cuid()) + kind String + key String + payload Json + expiresAt DateTime @map("expires_at") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@index([expiresAt], map: "mcp_oauth_state_expiresAt_idx") + @@map("mcp_oauth_state") +} diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts index 556f2e2eb..551bd7bcc 100644 --- a/packages/db/prisma/seed.ts +++ b/packages/db/prisma/seed.ts @@ -10,7 +10,8 @@ import { FieldType, RateSource, } from "../src/generated/prisma/enums"; -import { readReportingCurrency, SETTINGS_ID } from "../src/settings"; +import { runInTenant } from "../src/tenant-context"; +import { scopedDb } from "../src/tenant-scope"; function makeRandom(seed: number): () => number { let a = seed; @@ -375,9 +376,15 @@ async function seedCompanies( const companies = []; for (const company of COMPANIES) { - const row = await db.company.upsert({ - where: { domain: company.domain }, + const row = await scopedDb.company.upsert({ + where: { + organizationId_domain: { + organizationId: SEED_ORGANIZATION_ID, + domain: company.domain, + }, + }, create: { + organizationId: SEED_ORGANIZATION_ID, name: company.name, domain: company.domain, website: `https://${company.domain}`, @@ -415,7 +422,7 @@ async function seedIcons( const iconUrl = (await mirror(source, `companies/${company.id}/icon`)) ?? source; - await db.company.updateMany({ + await scopedDb.company.updateMany({ where: { id: company.id, iconUrl: null }, data: { iconUrl }, }); @@ -461,9 +468,16 @@ async function upsertField( options: readonly string[] = [], ): Promise { const key = fieldKeyFromLabel(label); - const definition = await db.fieldDefinition.upsert({ - where: { entity_key: { entity, key } }, + const definition = await scopedDb.fieldDefinition.upsert({ + where: { + organizationId_entity_key: { + organizationId: SEED_ORGANIZATION_ID, + entity, + key, + }, + }, create: { + organizationId: SEED_ORGANIZATION_ID, entity, key, label, @@ -474,6 +488,7 @@ async function upsertField( position, options: { create: options.map((optionLabel, index) => ({ + organizationId: SEED_ORGANIZATION_ID, label: optionLabel, position: index, })), @@ -542,7 +557,7 @@ async function seedCompanyFieldValues( const accountType = pick(ACCOUNT_TYPES); await Promise.all([ - db.fieldValue.upsert({ + scopedDb.fieldValue.upsert({ where: { fieldId_companyId: { fieldId: fields.accountType.id, @@ -550,13 +565,14 @@ async function seedCompanyFieldValues( }, }, create: { + organizationId: SEED_ORGANIZATION_ID, fieldId: fields.accountType.id, companyId: company.id, optionId: optionIdFor(fields.accountType, accountType), }, update: {}, }), - db.fieldValue.upsert({ + scopedDb.fieldValue.upsert({ where: { fieldId_companyId: { fieldId: fields.segment.id, @@ -564,13 +580,14 @@ async function seedCompanyFieldValues( }, }, create: { + organizationId: SEED_ORGANIZATION_ID, fieldId: fields.segment.id, companyId: company.id, optionId: optionIdFor(fields.segment, pick(SEGMENT_TIERS)), }, update: {}, }), - db.fieldValue.upsert({ + scopedDb.fieldValue.upsert({ where: { fieldId_companyId: { fieldId: fields.territory.id, @@ -578,13 +595,14 @@ async function seedCompanyFieldValues( }, }, create: { + organizationId: SEED_ORGANIZATION_ID, fieldId: fields.territory.id, companyId: company.id, optionId: optionIdFor(fields.territory, pick(TERRITORIES)), }, update: {}, }), - db.fieldValue.upsert({ + scopedDb.fieldValue.upsert({ where: { fieldId_companyId: { fieldId: fields.lifecycleStage.id, @@ -592,6 +610,7 @@ async function seedCompanyFieldValues( }, }, create: { + organizationId: SEED_ORGANIZATION_ID, fieldId: fields.lifecycleStage.id, companyId: company.id, optionId: optionIdFor( @@ -601,7 +620,7 @@ async function seedCompanyFieldValues( }, update: {}, }), - db.fieldValue.upsert({ + scopedDb.fieldValue.upsert({ where: { fieldId_companyId: { fieldId: fields.leadSource.id, @@ -609,13 +628,14 @@ async function seedCompanyFieldValues( }, }, create: { + organizationId: SEED_ORGANIZATION_ID, fieldId: fields.leadSource.id, companyId: company.id, optionId: optionIdFor(fields.leadSource, pick(LEAD_SOURCES)), }, update: {}, }), - db.fieldValue.upsert({ + scopedDb.fieldValue.upsert({ where: { fieldId_companyId: { fieldId: fields.icpFitScore.id, @@ -623,13 +643,14 @@ async function seedCompanyFieldValues( }, }, create: { + organizationId: SEED_ORGANIZATION_ID, fieldId: fields.icpFitScore.id, companyId: company.id, number: integer(40, 95), }, update: {}, }), - db.fieldValue.upsert({ + scopedDb.fieldValue.upsert({ where: { fieldId_companyId: { fieldId: fields.bdrOwner.id, @@ -637,6 +658,7 @@ async function seedCompanyFieldValues( }, }, create: { + organizationId: SEED_ORGANIZATION_ID, fieldId: fields.bdrOwner.id, companyId: company.id, userId: pick(ownerIds), @@ -664,9 +686,15 @@ async function seedContacts( if (used.has(email)) continue; used.add(email); - const contact = await db.contact.upsert({ - where: { email }, + const contact = await scopedDb.contact.upsert({ + where: { + organizationId_email: { + organizationId: SEED_ORGANIZATION_ID, + email, + }, + }, create: { + organizationId: SEED_ORGANIZATION_ID, firstName, lastName, email, @@ -687,7 +715,7 @@ async function seedContacts( for (const company of companies) { const first = contacts.find((contact) => contact.companyId === company.id); if (!first) continue; - await db.company.update({ + await scopedDb.company.update({ where: { id: company.id }, data: { primaryContactId: first.id }, }); @@ -714,23 +742,24 @@ const SEED_RATES: SeedRates = { }; const DEAL_CURRENCIES = ["USD", "USD", "USD", "EUR", "GBP", "JPY", "CAD"]; +const SEED_ORGANIZATION_ID = "workspace"; let seedBase = "USD"; async function seedRates(): Promise { const asOf = daysFromNow(-1); - await db.appSetting.upsert({ - where: { id: SETTINGS_ID }, + await scopedDb.appSetting.upsert({ + where: { organizationId: SEED_ORGANIZATION_ID }, create: { - id: SETTINGS_ID, + organizationId: SEED_ORGANIZATION_ID, reportingCurrency: DEFAULT_REPORTING_CURRENCY, }, update: {}, - select: { id: true }, + select: { organizationId: true }, }); - seedBase = await readReportingCurrency(db); + seedBase = DEFAULT_REPORTING_CURRENCY; if (seedBase !== "USD") { console.log( @@ -809,9 +838,10 @@ async function seedDeals( 12, ); - await db.deal.upsert({ + await scopedDb.deal.upsert({ where: { id }, create: { + organizationId: SEED_ORGANIZATION_ID, id, name: n === 0 @@ -854,9 +884,10 @@ async function seedDeals( (contact) => contact.companyId === company.id, ); for (const contact of companyContacts.slice(0, integer(1, 2))) { - await db.dealContact.upsert({ + await scopedDb.dealContact.upsert({ where: { dealId_contactId: { dealId: id, contactId: contact.id } }, create: { + organizationId: SEED_ORGANIZATION_ID, dealId: id, contactId: contact.id, role: chance(0.5) ? "Champion" : "Decision maker", @@ -878,13 +909,14 @@ async function seedActivities( deals: SeededDeal[], ownerIds: string[], ): Promise { - const existing = await db.activity.count(); + const existing = await scopedDb.activity.count(); if (existing > 0) { console.log(`Activities already seeded (${existing}) — skipping.`); return existing; } type ActivityRow = { + organizationId: string; type: ActivityType; subject: string | null; body: string | null; @@ -902,6 +934,7 @@ async function seedActivities( const rows: ActivityRow[] = []; const base = (companyId: string, createdById: string, createdAt: Date) => ({ + organizationId: SEED_ORGANIZATION_ID, companyId, contactId: null, dealId: null, @@ -987,19 +1020,40 @@ async function seedActivities( }); } - await db.activity.createMany({ data: rows }); + await scopedDb.activity.createMany({ data: rows }); return rows.length; } async function main() { - const rates = await seedRates(); - const ownerIds = await seedOwners(); - const companies = await seedCompanies(ownerIds); - const contacts = await seedContacts(companies, ownerIds); - const deals = await seedDeals(companies, contacts, ownerIds); - const activities = await seedActivities(companies, contacts, deals, ownerIds); - const companyFields = await seedCompanyFields(); - await seedCompanyFieldValues(companyFields, companies, ownerIds); + await db.organization.upsert({ + where: { id: SEED_ORGANIZATION_ID }, + create: { + id: SEED_ORGANIZATION_ID, + name: "Workspace", + slug: "workspace", + createdAt: new Date(), + }, + update: {}, + }); + const { activities, companies, contacts, deals, rates } = await runInTenant( + SEED_ORGANIZATION_ID, + async () => { + const rates = await seedRates(); + const ownerIds = await seedOwners(); + const companies = await seedCompanies(ownerIds); + const contacts = await seedContacts(companies, ownerIds); + const deals = await seedDeals(companies, contacts, ownerIds); + const activities = await seedActivities( + companies, + contacts, + deals, + ownerIds, + ); + const companyFields = await seedCompanyFields(); + await seedCompanyFieldValues(companyFields, companies, ownerIds); + return { activities, companies, contacts, deals, rates }; + }, + ); console.log( `Seeded ${companies.length} companies, ${contacts.length} contacts, ` + diff --git a/packages/db/scripts/provision-organization.ts b/packages/db/scripts/provision-organization.ts new file mode 100644 index 000000000..64339d1f4 --- /dev/null +++ b/packages/db/scripts/provision-organization.ts @@ -0,0 +1,134 @@ +import { db } from "../src/client"; +import type { Prisma } from "../src/generated/prisma/client"; +import { lockIdempotencyKey } from "../src/idempotency"; +import { workspaceSlug } from "../src/workspace-slug"; + +function readArg(flag: string): string | undefined { + const index = process.argv.indexOf(flag); + if (index === -1) return undefined; + const value = process.argv[index + 1]; + return value?.startsWith("--") ? undefined : value; +} + +function usage(message?: string): never { + console.error( + [ + message ? `\n ${message}` : "", + "", + ' Usage: bun run db:provision -- --name "Acme Inc" --owner-email owner@acme.com [--slug acme]', + "", + " Creates an organization and makes the existing user its owner.", + " The user must sign in before you run this command.", + "", + ].join("\n"), + ); + process.exit(1); +} + +function validateEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); +} + +async function uniqueSlug( + transaction: Pick, + base: string, +): Promise { + let candidate = base; + let attempt = 2; + + while ( + await transaction.organization.findUnique({ + where: { slug: candidate }, + select: { id: true }, + }) + ) { + candidate = `${base}-${attempt}`; + attempt += 1; + } + + return candidate; +} + +type ProvisionResult = { + organizationId: string; + name: string; + slug: string; + ownerEmail: string; +}; + +async function provision( + name: string, + ownerEmail: string, + baseSlug: string, +): Promise { + const owner = await db.user.findUnique({ + where: { email: ownerEmail }, + select: { id: true, email: true }, + }); + + if (!owner) return null; + + return db.$transaction(async (transaction) => { + await lockIdempotencyKey(transaction, `provision-organization:${baseSlug}`); + + const slug = await uniqueSlug(transaction, baseSlug); + const organizationId = crypto.randomUUID(); + const organization = await transaction.organization.create({ + data: { + id: organizationId, + name, + slug, + createdAt: new Date(), + }, + select: { id: true, name: true, slug: true }, + }); + + await transaction.member.create({ + data: { + id: crypto.randomUUID(), + organizationId: organization.id, + userId: owner.id, + role: "owner", + createdAt: new Date(), + }, + }); + + return { + organizationId: organization.id, + name: organization.name, + slug: organization.slug, + ownerEmail: owner.email, + }; + }); +} + +async function main(): Promise { + const name = readArg("--name")?.trim(); + const ownerEmail = readArg("--owner-email")?.trim().toLowerCase(); + const slugOverride = readArg("--slug")?.trim(); + + if (!name || !ownerEmail) usage("name and owner-email are required."); + if (!validateEmail(ownerEmail)) usage(`Invalid owner email "${ownerEmail}".`); + + return provision(name, ownerEmail, workspaceSlug(slugOverride || name)); +} + +main() + .then((result) => { + if (!result) { + console.error("No matching user exists. The owner must sign in first."); + process.exitCode = 1; + return; + } + + console.log( + `Created organization "${result.name}" (${result.organizationId}), slug "${result.slug}", owned by ${result.ownerEmail}.`, + ); + }) + .catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }) + .finally(async () => { + await db.$disconnect(); + }); diff --git a/packages/db/scripts/rehearse-migration.ts b/packages/db/scripts/rehearse-migration.ts new file mode 100644 index 000000000..f08ec16d6 --- /dev/null +++ b/packages/db/scripts/rehearse-migration.ts @@ -0,0 +1,220 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "../src/generated/prisma/client"; +import { WORKSPACE_ID } from "../src/workspace"; + +const MIGRATION = join( + dirname(import.meta.dirname), + "prisma", + "migrations", + "20260905120000_multi_tenant_schema", + "migration.sql", +); + +const ORGANIZATION_DEFAULT = + "current_setting('app.current_organization_id'::text, true)"; + +const SEEDED_MINIMUMS = { + company: 15, + contact: 25, + deal: 20, + dealContact: 20, + activity: 80, + fieldDefinition: 7, + fieldOption: 18, + fieldValue: 100, + appSetting: 1, +} as const; + +type CountRow = { + total: bigint; + wrongOrganization: bigint; +}; + +type ColumnRow = { + isNullable: "YES" | "NO"; + columnDefault: string | null; +}; + +type ForeignKeyRow = { + count: bigint; +}; + +type RlsRow = { + policyCount: bigint; + rowSecurity: boolean; + forceRowSecurity: boolean; +}; + +function tenantTables(): string[] { + const sql = readFileSync(MIGRATION, "utf8"); + const added = Array.from( + sql.matchAll(/ALTER TABLE "([^"]+)" ADD COLUMN "organizationId"/g), + (match) => match[1], + ); + const renamed = Array.from( + sql.matchAll( + /ALTER TABLE "([^"]+)" RENAME COLUMN "id" TO "organizationId"/g, + ), + (match) => match[1], + ); + + return [...new Set([...added, ...renamed])].sort(); +} + +function rlsTables(): string[] { + const sql = readFileSync(MIGRATION, "utf8"); + const array = sql.match(/FOREACH tbl IN ARRAY ARRAY\[(.*?)\]\s*LOOP/s)?.[1]; + + if (!array) throw new Error("RLS migration table list does not exist."); + + return Array.from(array.matchAll(/'([^']+)'/g), (match) => match[1]).sort(); +} + +function quotedIdentifier(value: string): string { + return `"${value.replaceAll('"', '""')}"`; +} + +async function counts(audit: PrismaClient, table: string): Promise { + const rows = await audit.$queryRawUnsafe( + `SELECT COUNT(*)::bigint AS "total", COUNT(*) FILTER (WHERE "organizationId" IS DISTINCT FROM $1)::bigint AS "wrongOrganization" FROM ${quotedIdentifier(table)}`, + WORKSPACE_ID, + ); + const row = rows[0]; + + if (!row) throw new Error(`No count result returned for ${table}.`); + + return row; +} + +async function column(audit: PrismaClient, table: string): Promise { + const rows = await audit.$queryRawUnsafe( + `SELECT "is_nullable" AS "isNullable", "column_default" AS "columnDefault" FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = 'organizationId'`, + table, + ); + const row = rows[0]; + + if (!row) throw new Error(`${table}.organizationId does not exist.`); + + return row; +} + +async function foreignKey(audit: PrismaClient, table: string): Promise { + const rows = await audit.$queryRawUnsafe( + `SELECT COUNT(*)::bigint AS "count" FROM pg_constraint c JOIN pg_class source ON source.oid = c.conrelid JOIN pg_namespace n ON n.oid = source.relnamespace JOIN unnest(c.conkey) AS key(attnum) ON true JOIN pg_attribute a ON a.attrelid = source.oid AND a.attnum = key.attnum WHERE c.contype = 'f' AND n.nspname = current_schema() AND source.relname = $1 AND a.attname = 'organizationId' AND c.confrelid = 'organization'::regclass`, + table, + ); + + return Number(rows[0]?.count ?? 0n); +} + +async function checkRlsCoverage(audit: PrismaClient): Promise { + const tables = rlsTables(); + let failures = 0; + + console.log(`Checking RLS coverage on ${tables.length} tenant tables.`); + + for (const table of tables) { + const rows = await audit.$queryRawUnsafe( + `SELECT c.relrowsecurity AS "rowSecurity", c.relforcerowsecurity AS "forceRowSecurity", COUNT(p.policyname)::bigint AS "policyCount" FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_policies p ON p.schemaname = n.nspname AND p.tablename = c.relname AND p.policyname = 'tenant_isolation' WHERE n.nspname = current_schema() AND c.relname = $1 GROUP BY c.relrowsecurity, c.relforcerowsecurity`, + table, + ); + const row = rows[0]; + const problems: string[] = []; + + if (!row?.rowSecurity) problems.push("row-level security is disabled"); + if (!row?.forceRowSecurity) + problems.push("row-level security is not forced"); + if (row?.policyCount !== 1n) + problems.push("tenant_isolation policy count is not one"); + + if (problems.length > 0) { + console.error(`FAIL ${table}: ${problems.join("; ")}`); + failures += 1; + continue; + } + + console.log(`ok ${table}: RLS enabled and forced`); + } + + return failures; +} + +async function rehearse(audit: PrismaClient): Promise { + const tables = tenantTables(); + let failures = 0; + + console.log( + `Checking ${tables.length} tables changed by the tenant backfill migration.`, + ); + + for (const table of tables) { + const organizationColumn = await column(audit, table); + const [rowCounts, organizationForeignKeys] = await Promise.all([ + counts(audit, table), + foreignKey(audit, table), + ]); + const minimum = SEEDED_MINIMUMS[table as keyof typeof SEEDED_MINIMUMS]; + const problems: string[] = []; + + if (rowCounts.wrongOrganization > 0n) { + problems.push(`${rowCounts.wrongOrganization} row(s) use another tenant`); + } + if (organizationColumn.isNullable !== "NO") { + problems.push("organizationId accepts null"); + } + if (organizationColumn.columnDefault !== ORGANIZATION_DEFAULT) { + problems.push("organizationId has the wrong tenant default"); + } + if (organizationForeignKeys !== 1) { + problems.push("organizationId lacks one organization foreign key"); + } + if (minimum !== undefined && rowCounts.total < BigInt(minimum)) { + problems.push(`seeded volume is below ${minimum}`); + } + + if (problems.length > 0) { + console.error( + `FAIL ${table}: ${rowCounts.total} row(s); ${problems.join("; ")}`, + ); + failures += 1; + continue; + } + + console.log(`ok ${table}: ${rowCounts.total} row(s)`); + } + + failures += await checkRlsCoverage(audit); + + if (failures > 0) { + throw new Error(`${failures} tenant migration check(s) failed.`); + } + + console.log("Tenant migration rehearsal passed."); +} + +async function main(): Promise { + const auditUrl = process.env.AUDIT_DATABASE_URL; + + if (!auditUrl) { + throw new Error( + "AUDIT_DATABASE_URL is required for the migration rehearsal. See docs/environment.md.", + ); + } + + const audit = new PrismaClient({ + adapter: new PrismaPg({ connectionString: auditUrl }), + }); + + try { + await rehearse(audit); + } finally { + await audit.$disconnect(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/db/scripts/test-db-create.ts b/packages/db/scripts/test-db-create.ts new file mode 100644 index 000000000..43ad0e9ce --- /dev/null +++ b/packages/db/scripts/test-db-create.ts @@ -0,0 +1,127 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import pg from "pg"; + +import { fail } from "./test-db-url"; + +const SCHEMA = join(dirname(import.meta.dirname), "prisma", "schema.prisma"); +const MIGRATIONS = join(dirname(import.meta.dirname), "prisma", "migrations"); + +export async function createTestDatabase( + target: string, + database: string, + forced: boolean, +): Promise { + const maintenance = new URL(target); + maintenance.pathname = "/postgres"; + maintenance.search = ""; + + const client = new pg.Client({ connectionString: maintenance.toString() }); + + try { + await client.connect(); + } catch (error) { + fail([ + `Could not reach the server at ${new URL(target).host}.`, + "Is Postgres running? docker compose up -d", + "", + error instanceof Error ? error.message : String(error), + ]); + } + + try { + const existing = await client.query( + "SELECT 1 FROM pg_database WHERE datname = $1", + [database], + ); + + if (existing.rowCount) { + const reason = forced + ? "you asked for --reset" + : await stale(target, database); + + if (!reason) { + console.log(` ${database} already exists`); + return; + } + + console.log(` rebuilding ${database}: ${reason}`); + await drop(client, database); + } + + await client.query(`CREATE DATABASE "${database}"`); + console.log(` created ${database}`); + } finally { + await client.end(); + } +} + +async function drop(client: pg.Client, database: string): Promise { + await client.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database], + ); + await client.query(`DROP DATABASE IF EXISTS "${database}"`); +} + +async function stale(target: string, database: string): Promise { + const applied = await appliedMigrations(target); + + if (applied === null) return null; + + const onDisk = new Set( + existsSync(MIGRATIONS) + ? readdirSync(MIGRATIONS, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + : [], + ); + + const foreign = applied.filter((migration) => !onDisk.has(migration)); + + if (foreign.length > 0) { + return `${database} holds ${foreign.length} migration(s) this branch does not have, starting with ${foreign[0]}`; + } + + return drifted(target) ? `${database} no longer matches schema.prisma` : null; +} + +async function appliedMigrations(target: string): Promise { + const client = new pg.Client({ connectionString: target }); + + try { + await client.connect(); + } catch { + return null; + } + + try { + const rows = await client.query<{ migration_name: string }>( + `SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL`, + ); + return rows.rows.map((row) => row.migration_name); + } catch { + return null; + } finally { + await client.end(); + } +} + +function drifted(target: string): boolean { + const result = spawnSync( + "prisma", + [ + "migrate", + "diff", + "--from-config-datasource", + "--to-schema", + SCHEMA, + "--exit-code", + ], + { stdio: "ignore", env: { ...process.env, DATABASE_URL: target } }, + ); + + return result.status === 2; +} diff --git a/packages/db/scripts/test-db-url.ts b/packages/db/scripts/test-db-url.ts new file mode 100644 index 000000000..026ca277f --- /dev/null +++ b/packages/db/scripts/test-db-url.ts @@ -0,0 +1,32 @@ +export function resolveTestDatabaseUrl(): string | null { + const explicit = process.env.TEST_DATABASE_URL; + if (explicit) return explicit; + + const live = process.env.DATABASE_URL; + if (!live) return null; + + try { + const parsed = new URL(live); + const database = parsed.pathname.replace(/^\//, ""); + if (!database) return null; + + parsed.pathname = `/${database.endsWith("_test") ? database : `${database}_test`}`; + + return parsed.toString(); + } catch { + return null; + } +} + +export function databaseName(value: string): string { + try { + return new URL(value).pathname.replace(/^\//, ""); + } catch { + return value; + } +} + +export function fail(lines: string[]): never { + console.error(["", ...lines.map((line) => ` ${line}`), ""].join("\n")); + process.exit(1); +} diff --git a/packages/db/scripts/test-db.ts b/packages/db/scripts/test-db.ts index 7deb36e5f..9d8e5c633 100644 --- a/packages/db/scripts/test-db.ts +++ b/packages/db/scripts/test-db.ts @@ -1,12 +1,11 @@ +import "@crm/env/load"; + import { spawnSync } from "node:child_process"; -import { existsSync, readdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import pg from "pg"; -const SCHEMA = join(dirname(import.meta.dirname), "prisma", "schema.prisma"); -const MIGRATIONS = join(dirname(import.meta.dirname), "prisma", "migrations"); +import { createTestDatabase } from "./test-db-create"; +import { databaseName, fail, resolveTestDatabaseUrl } from "./test-db-url"; -const url = resolve(); +const url = resolveTestDatabaseUrl(); if (!url) { fail([ @@ -25,7 +24,7 @@ if (!name.endsWith("_test")) { ]); } -await create(url, name, process.argv.includes("--reset")); +await createTestDatabase(url, name, process.argv.includes("--reset")); migrate(url); if (!process.env.TEST_DATABASE_URL) { @@ -40,124 +39,6 @@ if (!process.env.TEST_DATABASE_URL) { ); } -async function create( - target: string, - database: string, - forced: boolean, -): Promise { - const maintenance = new URL(target); - maintenance.pathname = "/postgres"; - maintenance.search = ""; - - const client = new pg.Client({ connectionString: maintenance.toString() }); - - try { - await client.connect(); - } catch (error) { - fail([ - `Could not reach the server at ${new URL(target).host}.`, - "Is Postgres running? docker compose up -d", - "", - error instanceof Error ? error.message : String(error), - ]); - } - - try { - const existing = await client.query( - "SELECT 1 FROM pg_database WHERE datname = $1", - [database], - ); - - if (existing.rowCount) { - const reason = forced - ? "you asked for --reset" - : await stale(target, database); - - if (!reason) { - console.log(` ${database} already exists`); - return; - } - - console.log(` rebuilding ${database}: ${reason}`); - await drop(client, database); - } - - await client.query(`CREATE DATABASE "${database}"`); - console.log(` created ${database}`); - } finally { - await client.end(); - } -} - -async function drop(client: pg.Client, database: string): Promise { - await client.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database], - ); - await client.query(`DROP DATABASE IF EXISTS "${database}"`); -} - -async function stale(target: string, database: string): Promise { - const applied = await appliedMigrations(target); - - if (applied === null) return null; - - const onDisk = new Set( - existsSync(MIGRATIONS) - ? readdirSync(MIGRATIONS, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - : [], - ); - - const foreign = applied.filter((migration) => !onDisk.has(migration)); - - if (foreign.length > 0) { - return `${database} holds ${foreign.length} migration(s) this branch does not have, starting with ${foreign[0]}`; - } - - return drifted(target) ? `${database} no longer matches schema.prisma` : null; -} - -async function appliedMigrations(target: string): Promise { - const client = new pg.Client({ connectionString: target }); - - try { - await client.connect(); - } catch { - return null; - } - - try { - const rows = await client.query<{ migration_name: string }>( - `SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL`, - ); - return rows.rows.map((row) => row.migration_name); - } catch { - return null; - } finally { - await client.end(); - } -} - -function drifted(target: string): boolean { - const result = spawnSync( - "prisma", - [ - "migrate", - "diff", - "--from-config-datasource", - "--to-schema", - SCHEMA, - "--exit-code", - ], - { stdio: "ignore", env: { ...process.env, DATABASE_URL: target } }, - ); - - return result.status === 2; -} - function migrate(target: string): void { const result = spawnSync("prisma", ["migrate", "deploy"], { stdio: "inherit", @@ -177,36 +58,3 @@ function migrate(target: string): void { if (result.status !== 0) process.exit(result.status ?? 1); } - -function resolve(): string | null { - const explicit = process.env.TEST_DATABASE_URL; - if (explicit) return explicit; - - const live = process.env.DATABASE_URL; - if (!live) return null; - - try { - const parsed = new URL(live); - const database = parsed.pathname.replace(/^\//, ""); - if (!database) return null; - - parsed.pathname = `/${database.endsWith("_test") ? database : `${database}_test`}`; - - return parsed.toString(); - } catch { - return null; - } -} - -function databaseName(value: string): string { - try { - return new URL(value).pathname.replace(/^\//, ""); - } catch { - return value; - } -} - -function fail(lines: string[]): never { - console.error(["", ...lines.map((line) => ` ${line}`), ""].join("\n")); - process.exit(1); -} diff --git a/packages/db/src/fields.ts b/packages/db/src/fields.ts index 4c34ad0b8..b20c0284f 100644 --- a/packages/db/src/fields.ts +++ b/packages/db/src/fields.ts @@ -291,7 +291,11 @@ export async function writeValues( where: { [`fieldId_${column}`]: { fieldId: definition.id, [column]: recordId }, }, - create: { fieldId: definition.id, [column]: recordId, ...data }, + create: { + fieldId: definition.id, + [column]: recordId, + ...data, + }, update: data, }); } diff --git a/packages/db/src/idempotency.ts b/packages/db/src/idempotency.ts index 4c7ac99ca..0e9fe1f74 100644 --- a/packages/db/src/idempotency.ts +++ b/packages/db/src/idempotency.ts @@ -1,7 +1,14 @@ import type { Prisma } from "./generated/prisma/client"; +type IdempotencyTransaction = { + $queryRaw( + query: TemplateStringsArray | Prisma.Sql, + ...values: unknown[] + ): Promise; +}; + export async function lockIdempotencyKey( - tx: Prisma.TransactionClient, + tx: IdempotencyTransaction, key: string, ): Promise { await tx.$queryRaw>` diff --git a/apps/agent/test/pool.spec.ts b/packages/db/src/pool.test.ts similarity index 97% rename from apps/agent/test/pool.spec.ts rename to packages/db/src/pool.test.ts index 534070e56..8bb5cae2c 100644 --- a/apps/agent/test/pool.spec.ts +++ b/packages/db/src/pool.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { collapsing, runLimited } from "../agent/lib/pool"; +import { collapsing, runLimited } from "./pool"; describe("runLimited", () => { it("runs every item", async () => { diff --git a/apps/agent/agent/lib/pool.ts b/packages/db/src/pool.ts similarity index 100% rename from apps/agent/agent/lib/pool.ts rename to packages/db/src/pool.ts diff --git a/packages/db/src/row-level-security.test.ts b/packages/db/src/row-level-security.test.ts new file mode 100644 index 000000000..1d62710d8 --- /dev/null +++ b/packages/db/src/row-level-security.test.ts @@ -0,0 +1,125 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "./client"; +import type { Prisma } from "./generated/prisma/client"; + +const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; +const ORG_A = `test-rls-org-a-${suffix}`; +const ORG_B = `test-rls-org-b-${suffix}`; + +beforeAll(async () => { + await db.organization.createMany({ + data: [ + { id: ORG_A, name: "RLS Org A", slug: ORG_A, createdAt: new Date() }, + { id: ORG_B, name: "RLS Org B", slug: ORG_B, createdAt: new Date() }, + ], + }); +}); + +afterAll(async () => { + await db.company.deleteMany({ + where: { organizationId: { in: [ORG_A, ORG_B] } }, + }); + await db.organization.deleteMany({ where: { id: { in: [ORG_A, ORG_B] } } }); +}); + +function asTenant( + organizationId: string | null, + work: (tx: Prisma.TransactionClient) => Promise, +): Promise { + return db.$transaction(async (tx) => { + if (organizationId !== null) { + await tx.$executeRaw` + SELECT set_config('app.current_organization_id', ${organizationId}, true) + `; + } + return work(tx); + }); +} + +describe("Postgres row-level security", () => { + it("uses a non-bypass application database role", async () => { + const [role] = await db.$queryRaw< + { bypassRls: boolean; superuser: boolean }[] + >` + SELECT rolbypassrls AS "bypassRls", rolsuper AS superuser + FROM pg_roles + WHERE rolname = current_user + `; + + expect(role).toEqual({ bypassRls: false, superuser: false }); + }); + + it("hides another organization's row from a raw select", async () => { + const inA = await asTenant(ORG_A, (tx) => + tx.company.create({ data: { name: "Visible to A" } }), + ); + + const seenFromB = await asTenant( + ORG_B, + (tx) => + tx.$queryRaw<{ id: string }[]>` + SELECT id FROM "company" WHERE id = ${inA.id} + `, + ); + + expect(seenFromB).toHaveLength(0); + }); + + it("refuses an insert for another organization", async () => { + await expect( + asTenant( + ORG_A, + (tx) => + tx.$executeRaw` + INSERT INTO "company" (id, "organizationId", name, "createdAt", "updatedAt") + VALUES (${`${ORG_B}-sneak`}, ${ORG_B}, 'Sneak', now(), now()) + `, + ), + ).rejects.toThrow(); + }); + + it("forces policies for the table owner", async () => { + const [company] = await db.$queryRaw< + { currentUser: string; forceRowSecurity: boolean; tableOwner: string }[] + >` + SELECT + current_user AS "currentUser", + pg_get_userbyid(c.relowner) AS "tableOwner", + c.relforcerowsecurity AS "forceRowSecurity" + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = current_schema() AND c.relname = 'company' + `; + + expect(company?.currentUser).toBe(company?.tableOwner); + expect(company?.forceRowSecurity).toBe(true); + + const rows = await asTenant( + ORG_A, + (tx) => + tx.$queryRaw<{ organizationId: string }[]>` + SELECT "organizationId" + FROM "company" + WHERE "organizationId" = ${ORG_B} + `, + ); + + expect(rows).toHaveLength(0); + }); + + it("defaults organizationId from the transaction tenant", async () => { + const created = await asTenant(ORG_A, (tx) => + tx.company.create({ data: { name: "Defaulted" } }), + ); + + expect(created.organizationId).toBe(ORG_A); + }); + + it("fails closed without a tenant transaction", async () => { + await expect( + asTenant(null, (tx) => + tx.company.create({ data: { name: "No tenant context" } }), + ), + ).rejects.toThrow(); + }); +}); diff --git a/packages/db/src/settings.test.ts b/packages/db/src/settings.test.ts new file mode 100644 index 000000000..6b519fd7d --- /dev/null +++ b/packages/db/src/settings.test.ts @@ -0,0 +1,107 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "./client"; +import { DEFAULT_REPORTING_CURRENCY } from "./currency"; +import type { Prisma } from "./generated/prisma/client"; +import { + DEFAULT_AGENT_MODEL, + readAgentModel, + readContextDevKey, + readRatesRefreshedAt, + readReportingCurrency, + writeAgentModel, + writeContextDevKey, + writeRatesRefreshedAt, + writeReportingCurrency, +} from "./settings"; +import { runInTenant, TenantContextError } from "./tenant-context"; +import { scopedTransaction } from "./tenant-scope"; + +const testPrefix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; +const ORG_A = `settingsfn-org-a-${testPrefix}`; +const ORG_B = `settingsfn-org-b-${testPrefix}`; + +const inTenant = ( + organizationId: string, + work: (tx: Prisma.TransactionClient) => Promise, +) => runInTenant(organizationId, () => scopedTransaction(work)); + +beforeAll(async () => { + await db.organization.createMany({ + data: [ + { + id: ORG_A, + name: `Org A ${testPrefix}`, + slug: `settingsfn-org-a-${testPrefix}`, + createdAt: new Date(), + }, + { + id: ORG_B, + name: `Org B ${testPrefix}`, + slug: `settingsfn-org-b-${testPrefix}`, + createdAt: new Date(), + }, + ], + skipDuplicates: true, + }); +}); + +afterAll(async () => { + for (const organizationId of [ORG_A, ORG_B]) { + await inTenant(organizationId, (tx) => tx.appSetting.deleteMany()); + } + await db.organization.deleteMany({ where: { id: { in: [ORG_A, ORG_B] } } }); +}); + +describe("settings readers/writers outside any tenant context", () => { + it("throw TenantContextError rather than reading anyone's row", async () => { + await expect(readAgentModel(db)).rejects.toThrow(TenantContextError); + await expect(readContextDevKey(db)).rejects.toThrow(TenantContextError); + await expect(readReportingCurrency(db)).rejects.toThrow(TenantContextError); + await expect(readRatesRefreshedAt(db)).rejects.toThrow(TenantContextError); + }); +}); + +describe("settings readers/writers inside tenant context", () => { + it("keeps the agent model choice isolated per organization", async () => { + await inTenant(ORG_A, (tx) => + writeAgentModel(tx, { + id: "openai/gpt-5.5", + contextWindowTokens: 1, + }), + ); + + const inA = await inTenant(ORG_A, (tx) => readAgentModel(tx)); + const inB = await inTenant(ORG_B, (tx) => readAgentModel(tx)); + + expect(inA.id).toBe("openai/gpt-5.5"); + expect(inA.isDefault).toBe(false); + expect(inB.isDefault).toBe(true); + expect(inB.id).toBe(DEFAULT_AGENT_MODEL.id); + }); + + it("keeps the Context.dev key isolated per organization", async () => { + await inTenant(ORG_A, (tx) => writeContextDevKey(tx, "key-a")); + + expect(await inTenant(ORG_A, (tx) => readContextDevKey(tx))).toBe("key-a"); + expect(await inTenant(ORG_B, (tx) => readContextDevKey(tx))).toBeNull(); + }); + + it("keeps the reporting currency isolated per organization", async () => { + await inTenant(ORG_A, (tx) => writeReportingCurrency(tx, "EUR")); + + expect(await inTenant(ORG_A, (tx) => readReportingCurrency(tx))).toBe( + "EUR", + ); + expect(await inTenant(ORG_B, (tx) => readReportingCurrency(tx))).toBe( + DEFAULT_REPORTING_CURRENCY, + ); + }); + + it("keeps the rates-refreshed timestamp isolated per organization", async () => { + const at = new Date("2026-08-01T00:00:00.000Z"); + await inTenant(ORG_A, (tx) => writeRatesRefreshedAt(tx, at)); + + expect(await inTenant(ORG_A, (tx) => readRatesRefreshedAt(tx))).toEqual(at); + expect(await inTenant(ORG_B, (tx) => readRatesRefreshedAt(tx))).toBeNull(); + }); +}); diff --git a/packages/db/src/settings.ts b/packages/db/src/settings.ts index 2959bf05b..001235a32 100644 --- a/packages/db/src/settings.ts +++ b/packages/db/src/settings.ts @@ -4,23 +4,49 @@ import { isCurrencyCode, normalizeCurrency, } from "./currency"; +import type { Prisma } from "./generated/prisma/client"; +import { currentOrganizationId } from "./tenant-context"; -export const SETTINGS_ID = "app"; +type SettingsDb = Pick; export const DEFAULT_AGENT_MODEL = { id: "zai/glm-5.2-fast", contextWindowTokens: 1_000_000, } as const; +export function defaultAgentModelResult() { + return { + model: DEFAULT_AGENT_MODEL.id, + modelContextWindowTokens: DEFAULT_AGENT_MODEL.contextWindowTokens, + }; +} + export interface AgentModelSetting { id: string; contextWindowTokens: number; isDefault: boolean; } -export async function readAgentModel(db: Db): Promise { +type AgentModelReader = { + appSetting: { + findUnique(args: { + where: { organizationId: string }; + select: { + agentModelId: true; + agentModelContextWindow: true; + }; + }): PromiseLike<{ + agentModelId: string | null; + agentModelContextWindow: number | null; + } | null>; + }; +}; + +export async function readAgentModel( + db: AgentModelReader, +): Promise { const row = await db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, + where: { organizationId: currentOrganizationId() }, select: { agentModelId: true, agentModelContextWindow: true }, }); @@ -37,18 +63,12 @@ export async function readAgentModel(db: Db): Promise { } export async function writeAgentModel( - db: Db, + db: SettingsDb, model: { id: string; contextWindowTokens: number } | null, ): Promise { - const fields = { + await writeAppSetting(db, { agentModelId: model?.id ?? null, agentModelContextWindow: model?.contextWindowTokens ?? null, - }; - - await db.appSetting.upsert({ - where: { id: SETTINGS_ID }, - create: { id: SETTINGS_ID, ...fields }, - update: fields, }); } @@ -56,28 +76,27 @@ export const CONTEXT_DEV_SIGNUP_URL = "https://link.context.dev/crm"; export const CONTEXT_DEV_DISCOUNT_CODE = "CRM"; -export async function readContextDevKey(db: Db): Promise { +export async function readContextDevKey( + db: SettingsDb, +): Promise { const row = await db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, + where: { organizationId: currentOrganizationId() }, select: { contextDevApiKey: true }, }); return row?.contextDevApiKey?.trim() || null; } -export async function writeContextDevKey(db: Db, key: string): Promise { - const contextDevApiKey = key.trim(); - - await db.appSetting.upsert({ - where: { id: SETTINGS_ID }, - create: { id: SETTINGS_ID, contextDevApiKey }, - update: { contextDevApiKey }, - }); +export async function writeContextDevKey( + db: SettingsDb, + key: string, +): Promise { + await writeAppSetting(db, { contextDevApiKey: key.trim() }); } -export async function readReportingCurrency(db: Db): Promise { +export async function readReportingCurrency(db: SettingsDb): Promise { const row = await db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, + where: { organizationId: currentOrganizationId() }, select: { reportingCurrency: true }, }); @@ -87,23 +106,21 @@ export async function readReportingCurrency(db: Db): Promise { } export async function writeReportingCurrency( - db: Db, + db: SettingsDb, code: string, ): Promise { const reportingCurrency = normalizeCurrency(code); - await db.appSetting.upsert({ - where: { id: SETTINGS_ID }, - create: { id: SETTINGS_ID, reportingCurrency }, - update: { reportingCurrency }, - }); + await writeAppSetting(db, { reportingCurrency }); return reportingCurrency; } -export async function readRatesRefreshedAt(db: Db): Promise { +export async function readRatesRefreshedAt( + db: SettingsDb, +): Promise { const row = await db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, + where: { organizationId: currentOrganizationId() }, select: { ratesRefreshedAt: true }, }); @@ -111,14 +128,10 @@ export async function readRatesRefreshedAt(db: Db): Promise { } export async function writeRatesRefreshedAt( - db: Db, + db: SettingsDb, ratesRefreshedAt: Date, ): Promise { - await db.appSetting.upsert({ - where: { id: SETTINGS_ID }, - create: { id: SETTINGS_ID, ratesRefreshedAt }, - update: { ratesRefreshedAt }, - }); + await writeAppSetting(db, { ratesRefreshedAt }); } export const DEFAULT_ARCHIVE_RETENTION_DAYS = 180; @@ -127,9 +140,12 @@ export const MIN_ARCHIVE_RETENTION_DAYS = 1; export const MAX_ARCHIVE_RETENTION_DAYS = 3650; -export async function readArchiveRetentionDays(db: Db): Promise { +export async function readArchiveRetentionDays( + db: SettingsDb, +): Promise { + const organizationId = currentOrganizationId(); const row = await db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, + where: { organizationId }, select: { archiveRetentionDays: true }, }); @@ -137,7 +153,7 @@ export async function readArchiveRetentionDays(db: Db): Promise { } export async function writeArchiveRetentionDays( - db: Db, + db: SettingsDb, days: number, ): Promise { const archiveRetentionDays = Math.min( @@ -145,11 +161,7 @@ export async function writeArchiveRetentionDays( MAX_ARCHIVE_RETENTION_DAYS, ); - await db.appSetting.upsert({ - where: { id: SETTINGS_ID }, - create: { id: SETTINGS_ID, archiveRetentionDays }, - update: { archiveRetentionDays }, - }); + await writeAppSetting(db, { archiveRetentionDays }); return archiveRetentionDays; } @@ -158,3 +170,21 @@ export function maskKey(key: string): string { const trimmed = key.trim(); return trimmed.length > 4 ? `••••${trimmed.slice(-4)}` : "••••"; } + +type AppSettingFields = Omit< + Prisma.AppSettingUncheckedCreateInput, + "organizationId" +>; + +async function writeAppSetting( + db: SettingsDb, + fields: AppSettingFields, +): Promise { + const organizationId = currentOrganizationId(); + + await db.appSetting.upsert({ + where: { organizationId }, + create: fields, + update: fields, + }); +} diff --git a/packages/db/src/slack-inventory.test.ts b/packages/db/src/slack-inventory.test.ts new file mode 100644 index 000000000..f4cf053a6 --- /dev/null +++ b/packages/db/src/slack-inventory.test.ts @@ -0,0 +1,61 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "./client"; +import { queueSlackInventorySync, SLACK_INVENTORY } from "./slack-inventory"; +import { runInTenant } from "./tenant-context"; +import { scopedTransaction } from "./tenant-scope"; + +const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; +const organizationId = `slack-inventory-${suffix}`; + +beforeAll(async () => { + await db.organization.create({ + data: { + id: organizationId, + name: `Slack inventory ${suffix}`, + slug: organizationId, + createdAt: new Date(), + }, + }); +}); + +afterAll(async () => { + await runInTenant(organizationId, () => + scopedTransaction((tx) => + tx.agentTask.deleteMany({ where: { organizationId } }), + ), + ); + await db.organization.delete({ where: { id: organizationId } }); +}); + +describe("Slack inventory queue", () => { + it("enters the organization tenant before creating work", async () => { + const reason = `Refresh Slack inventory ${suffix}`; + + await queueSlackInventorySync(reason, organizationId); + + const task = await runInTenant(organizationId, () => + scopedTransaction((tx) => + tx.agentTask.findFirst({ + where: { organizationId, kind: SLACK_INVENTORY.kind }, + }), + ), + ); + + expect(task?.organizationId).toBe(organizationId); + expect(task?.reason).toBe(reason); + }); + + it("keeps one recent task for the organization", async () => { + await queueSlackInventorySync(`Duplicate ${suffix}`, organizationId); + + const count = await runInTenant(organizationId, () => + scopedTransaction((tx) => + tx.agentTask.count({ + where: { organizationId, kind: SLACK_INVENTORY.kind }, + }), + ), + ); + + expect(count).toBe(1); + }); +}); diff --git a/packages/db/src/slack-inventory.ts b/packages/db/src/slack-inventory.ts index ae4aaa0cb..29891a119 100644 --- a/packages/db/src/slack-inventory.ts +++ b/packages/db/src/slack-inventory.ts @@ -1,6 +1,8 @@ import { PRIORITY } from "./agent-tasks"; -import { db } from "./client"; import { lockIdempotencyKey } from "./idempotency"; +import { runInTenant } from "./tenant-context"; +import { scopedTransaction } from "./tenant-scope"; +import { organizationIds } from "./tenants"; const MINUTE_MS = 60_000; @@ -12,33 +14,44 @@ export const SLACK_INVENTORY = { throttleMs: 15 * MINUTE_MS, } as const; -export async function queueSlackInventorySync(reason: string): Promise { +export async function queueSlackInventorySync( + reason: string, + organizationId?: string, +): Promise { const since = new Date(Date.now() - SLACK_INVENTORY.throttleMs); + const organizations = organizationId + ? [organizationId] + : await organizationIds(); - try { - await db.$transaction(async (tx) => { - await lockIdempotencyKey(tx, SLACK_INVENTORY.lock); + for (const organizationId of organizations) { + try { + await runInTenant(organizationId, () => + scopedTransaction(async (tx) => { + await lockIdempotencyKey( + tx, + `${SLACK_INVENTORY.lock}:${organizationId}`, + ); - const recent = await tx.agentTask.findFirst({ - where: { - kind: SLACK_INVENTORY.kind, - OR: [{ finishedAt: null }, { createdAt: { gt: since } }], - }, - select: { id: true }, - }); - if (recent) return; + const recent = await tx.agentTask.findFirst({ + where: { + kind: SLACK_INVENTORY.kind, + OR: [{ finishedAt: null }, { createdAt: { gt: since } }], + }, + select: { id: true }, + }); + if (recent) return; - await tx.agentTask.create({ - data: { - kind: SLACK_INVENTORY.kind, - reason, - priority: SLACK_INVENTORY.priority, - budget: SLACK_INVENTORY.budget, - dueAt: new Date(), - }, - }); - }); - } catch { - return; + await tx.agentTask.create({ + data: { + kind: SLACK_INVENTORY.kind, + reason, + priority: SLACK_INVENTORY.priority, + budget: SLACK_INVENTORY.budget, + dueAt: new Date(), + }, + }); + }), + ); + } catch {} } } diff --git a/packages/db/src/sso-provider-tenant-scope.test.ts b/packages/db/src/sso-provider-tenant-scope.test.ts new file mode 100644 index 000000000..3432f5bb7 --- /dev/null +++ b/packages/db/src/sso-provider-tenant-scope.test.ts @@ -0,0 +1,89 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "./client"; +import { Prisma } from "./generated/prisma/client"; +import { runInTenant, TenantContextError } from "./tenant-context"; +import { scopedDb, scopedTransaction } from "./tenant-scope"; + +const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; +const ORG_A = `sso-scope-org-a-${suffix}`; +const ORG_B = `sso-scope-org-b-${suffix}`; +const PROVIDER_A = `sso-scope-provider-a-${suffix}`; +const PROVIDER_B = `sso-scope-provider-b-${suffix}`; + +beforeAll(async () => { + await db.organization.createMany({ + data: [ + { + id: ORG_A, + name: `SSO Scope A ${suffix}`, + slug: ORG_A, + createdAt: new Date(), + }, + { + id: ORG_B, + name: `SSO Scope B ${suffix}`, + slug: ORG_B, + createdAt: new Date(), + }, + ], + }); +}); + +afterAll(async () => { + for (const organizationId of [ORG_A, ORG_B]) { + await runInTenant(organizationId, () => + scopedTransaction((tx) => tx.ssoProvider.deleteMany()), + ); + } + await db.organization.deleteMany({ + where: { id: { in: [ORG_A, ORG_B] } }, + }); +}); + +describe("SsoProvider tenant scoping", () => { + it("requires organizationId at the database level", async () => { + await expect( + Promise.resolve( + db.$executeRaw( + Prisma.sql`INSERT INTO "ssoProvider" ("id", "issuer", "providerId", "domain") VALUES (${`sso-scope-no-org-${suffix}`}, ${"https://issuer.example.com"}, ${`sso-scope-no-org-${suffix}`}, ${"example.com"})`, + ), + ), + ).rejects.toThrow(); + }); + + it("throws outside tenant context", async () => { + await expect( + Promise.resolve(scopedDb.ssoProvider.findMany()), + ).rejects.toThrow(TenantContextError); + }); + + it("defaults and isolates providers", async () => { + await runInTenant(ORG_A, () => + scopedDb.ssoProvider.create({ + data: { + id: `sso-scope-row-a-${suffix}`, + providerId: PROVIDER_A, + issuer: "https://issuer-a.example.com", + domain: "a.example.com", + }, + }), + ); + await runInTenant(ORG_B, () => + scopedDb.ssoProvider.create({ + data: { + id: `sso-scope-row-b-${suffix}`, + providerId: PROVIDER_B, + issuer: "https://issuer-b.example.com", + domain: "b.example.com", + }, + }), + ); + + const seenFromA = await runInTenant(ORG_A, () => + scopedDb.ssoProvider.findMany({ orderBy: { providerId: "asc" } }), + ); + + expect(seenFromA.map((row) => row.providerId)).toEqual([PROVIDER_A]); + expect(seenFromA[0]?.organizationId).toBe(ORG_A); + }); +}); diff --git a/packages/db/src/tenant-context.test.ts b/packages/db/src/tenant-context.test.ts new file mode 100644 index 000000000..a2cacc8ae --- /dev/null +++ b/packages/db/src/tenant-context.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + currentOrganizationId, + runInTenant, + TenantContextError, + tryCurrentOrganizationId, +} from "./tenant-context"; + +describe("tenant context", () => { + afterEach(() => { + expect.hasAssertions(); + }); + + it("has no context outside runInTenant", () => { + expect(tryCurrentOrganizationId()).toBeUndefined(); + }); + + it("throws TenantContextError when currentOrganizationId is used outside context", () => { + expect(() => { + currentOrganizationId(); + }).toThrow(TenantContextError); + }); + + it("makes the organization ID available inside runInTenant", () => { + const organizationId = runInTenant("org-1", () => { + return currentOrganizationId(); + }); + + expect(organizationId).toBe("org-1"); + }); + + it("returns the callback result from runInTenant", () => { + const result = runInTenant("org-2", () => { + return { + ok: true, + organizationId: currentOrganizationId(), + }; + }); + + expect(result).toEqual({ + ok: true, + organizationId: "org-2", + }); + }); + + it("does not leak context between sibling runs", async () => { + const [a, b] = await Promise.all([ + runInTenant("org-1", async () => { + await Promise.resolve(); + return currentOrganizationId(); + }), + runInTenant("org-2", async () => { + await Promise.resolve(); + return currentOrganizationId(); + }), + ]); + + expect(a).toBe("org-1"); + expect(b).toBe("org-2"); + expect(tryCurrentOrganizationId()).toBeUndefined(); + expect(() => { + currentOrganizationId(); + }).toThrow(TenantContextError); + }); + + it("restores outer context when nested runInTenant calls", () => { + const result = runInTenant("outer", () => { + expect(currentOrganizationId()).toBe("outer"); + + const inner = runInTenant("inner", () => { + expect(currentOrganizationId()).toBe("inner"); + return currentOrganizationId(); + }); + + expect(currentOrganizationId()).toBe("outer"); + expect(inner).toBe("inner"); + + return currentOrganizationId(); + }); + + expect(result).toBe("outer"); + }); + + it("propagates context across Promise boundaries", async () => { + const result = await runInTenant("org-async", async () => { + await Promise.resolve("step"); + return currentOrganizationId(); + }); + + expect(result).toBe("org-async"); + }); +}); diff --git a/packages/db/src/tenant-context.ts b/packages/db/src/tenant-context.ts new file mode 100644 index 000000000..194bbc0da --- /dev/null +++ b/packages/db/src/tenant-context.ts @@ -0,0 +1,53 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export class TenantContextError extends Error { + constructor() { + super( + "No active tenant context. Every call into scopedDb must run inside runInTenant(organizationId, fn).", + ); + this.name = "TenantContextError"; + } +} + +interface TenantStore { + organizationId: string; +} + +const storage = new AsyncLocalStorage(); + +export function runInTenant( + organizationId: string, + fn: () => PromiseLike, +): Promise; +export function runInTenant(organizationId: string, fn: () => T): T; +export function runInTenant( + organizationId: string, + fn: () => T | PromiseLike, +): T | Promise { + return storage.run({ organizationId }, () => { + const result = fn(); + return isPromiseLike(result) ? Promise.resolve(result) : result; + }); +} + +export function tryCurrentOrganizationId(): string | undefined { + return storage.getStore()?.organizationId; +} + +export function currentOrganizationId(): string { + const organizationId = tryCurrentOrganizationId(); + + if (!organizationId) { + throw new TenantContextError(); + } + + return organizationId; +} + +function isPromiseLike(value: T | PromiseLike): value is PromiseLike { + return ( + value instanceof Object && + "then" in value && + (value as { then: unknown }).then instanceof Function + ); +} diff --git a/packages/db/src/tenant-policy-foundation.test.ts b/packages/db/src/tenant-policy-foundation.test.ts new file mode 100644 index 000000000..cc5079d38 --- /dev/null +++ b/packages/db/src/tenant-policy-foundation.test.ts @@ -0,0 +1,144 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "./client"; +import { Prisma } from "./generated/prisma/client"; +import { runInTenant } from "./tenant-context"; +import { scopedTransaction } from "./tenant-scope"; + +const TABLES = [ + "company", + "contact", + "deal", + "dealContact", + "activity", + "appointmentDetails", + "fieldDefinition", + "fieldOption", + "fieldValue", + "companyEnrichment", + "contactFact", + "contactBrief", + "agentTask", + "agentEvent", + "agentConversation", + "agentConversationFeedback", + "agentConversationShare", + "agentConversationSubmission", + "agentConversationAttachment", + "agentDefinition", + "agentVersion", + "agentBuilderArtifact", + "agentTrigger", + "agentRun", + "agentRunEvent", + "agentAction", + "agentAuditEvent", + "mailboxSync", + "emailThread", + "emailMessage", + "calendarEvent", + "calendarAttendee", + "appSetting", + "workspaceProfile", + "ssoProvider", + "slackInstallation", + "slackWorkspaceGrant", + "slackChannel", + "slackMemberMatch", + "trackedDomain", + "trackedVisitor", + "trackedEvent", + "trackedPageDaily", + "formSubmission", + "trackingCounter", + "suppressedDomain", + "suppressedContact", + "savedView", +] as const; + +type PolicyState = { + tableName: string; + rowSecurity: boolean; + forceRowSecurity: boolean; + defaultExpression: string; + policyName: string; + policyCommand: string; + usingExpression: string; + checkExpression: string; +}; + +const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; +const organizationId = `tenant-policy-org-${suffix}`; + +beforeAll(async () => { + await db.organization.create({ + data: { + id: organizationId, + name: `Tenant Policy ${suffix}`, + slug: organizationId, + createdAt: new Date(), + }, + }); +}); + +afterAll(async () => { + await runInTenant(organizationId, () => + scopedTransaction((tx) => tx.company.deleteMany()), + ); + await db.organization.deleteMany({ where: { id: organizationId } }); +}); + +describe("tenant policy foundation", () => { + it("creates one active tenant policy and one tenant default on every scoped table", async () => { + const rows = await db.$queryRaw(Prisma.sql` + SELECT + c.relname AS "tableName", + c.relrowsecurity AS "rowSecurity", + c.relforcerowsecurity AS "forceRowSecurity", + pg_get_expr(d.adbin, d.adrelid) AS "defaultExpression", + p.polname AS "policyName", + p.polcmd::text AS "policyCommand", + pg_get_expr(p.polqual, p.polrelid) AS "usingExpression", + pg_get_expr(p.polwithcheck, p.polrelid) AS "checkExpression" + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'organizationId' + JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum + JOIN pg_policy p ON p.polrelid = c.oid + WHERE n.nspname = current_schema() + AND c.relname IN (${Prisma.join(TABLES)}) + ORDER BY c.relname + `); + + expect(rows.map((row) => row.tableName)).toEqual([...TABLES].sort()); + + for (const row of rows) { + expect(row.rowSecurity).toBe(true); + expect(row.forceRowSecurity).toBe(true); + expect(row.defaultExpression).toBe( + "current_setting('app.current_organization_id'::text, true)", + ); + expect(row.policyName).toBe("tenant_isolation"); + expect(row.policyCommand).toBe("*"); + expect(row.usingExpression).toContain( + "current_setting('app.current_organization_id'::text, true)", + ); + expect(row.checkExpression).toContain( + "current_setting('app.current_organization_id'::text, true)", + ); + } + }); + + it("fills organizationId from the transaction-local tenant value", async () => { + const created = await db.$transaction(async (tx) => { + await tx.$executeRaw` + SELECT set_config('app.current_organization_id', ${organizationId}, true) + `; + + return tx.company.create({ + data: { name: `Defaulted Company ${suffix}` }, + }); + }); + + expect(created.organizationId).toBe(organizationId); + }); +}); diff --git a/packages/db/src/tenant-scope.test.ts b/packages/db/src/tenant-scope.test.ts new file mode 100644 index 000000000..4a8fb8075 --- /dev/null +++ b/packages/db/src/tenant-scope.test.ts @@ -0,0 +1,493 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "./client"; +import { runInTenant, TenantContextError } from "./tenant-context"; +import { type ScopedDb, scopedDb, scopedTransaction } from "./tenant-scope"; + +const testPrefix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; +const ORG_A = `tenant-scope-org-a-${testPrefix}`; +const ORG_B = `tenant-scope-org-b-${testPrefix}`; +let userId: string; + +const makeName = (label: string) => + `${testPrefix}-${label}-${Math.random().toString(16).slice(2)}`; + +beforeAll(async () => { + await db.organization.createMany({ + data: [ + { + id: ORG_A, + name: `Org A ${testPrefix}`, + slug: `org-a-${testPrefix}`, + createdAt: new Date(), + }, + { + id: ORG_B, + name: `Org B ${testPrefix}`, + slug: `org-b-${testPrefix}`, + createdAt: new Date(), + }, + ], + skipDuplicates: true, + }); + const user = await db.user.create({ + data: { + id: `tenant-scope-user-${testPrefix}`, + name: "Tenant Scope User", + email: `tenant-scope-${testPrefix}@example.com`, + }, + }); + userId = user.id; +}); + +afterAll(async () => { + for (const organizationId of [ORG_A, ORG_B]) { + await runInTenant(organizationId, () => + scopedTransaction(async (tx) => { + await tx.agentConversation.deleteMany(); + await tx.agentDefinition.deleteMany(); + await tx.agentTask.deleteMany(); + await tx.mailboxSync.deleteMany(); + await tx.suppressedContact.deleteMany(); + await tx.appSetting.deleteMany(); + await tx.contact.deleteMany(); + await tx.company.deleteMany(); + }), + ); + } + await db.user.delete({ where: { id: userId } }); + await db.organization.deleteMany({ + where: { id: { in: [ORG_A, ORG_B] } }, + }); +}); + +describe("tenant scoping", () => { + it("throws TenantContextError for read and create when no tenant context is active", async () => { + await expect(Promise.resolve(scopedDb.company.findMany())).rejects.toThrow( + TenantContextError, + ); + await expect( + Promise.resolve( + scopedDb.company.create({ + data: { + name: makeName("outside-create"), + organizationId: ORG_A, + }, + }), + ), + ).rejects.toThrow(TenantContextError); + }); + + it("defaults organizationId on create", async () => { + const name = makeName("stamp"); + const created = await runInTenant(ORG_A, () => + scopedDb.company.create({ data: { name } }), + ); + + expect(created.organizationId).toBe(ORG_A); + }); + + it("rejects a supplied organizationId from another tenant", async () => { + const name = makeName("overwrite"); + + await expect( + runInTenant(ORG_A, () => + scopedDb.company.create({ + data: { name, organizationId: ORG_B }, + }), + ), + ).rejects.toThrow(); + }); + + it("keeps organizationId during update and upsert", async () => { + const company = await runInTenant(ORG_A, () => + scopedDb.company.create({ data: { name: makeName("update-source") } }), + ); + const updatedName = makeName("update-result"); + const upsertedName = makeName("upsert-result"); + + const updated = await runInTenant(ORG_A, () => + scopedDb.company.update({ + where: { id: company.id }, + data: { name: updatedName }, + }), + ); + const upserted = await runInTenant(ORG_A, () => + scopedDb.company.upsert({ + where: { id: company.id }, + create: { + name: makeName("upsert-create"), + }, + update: { name: upsertedName }, + }), + ); + + expect(updated.organizationId).toBe(ORG_A); + expect(updated.name).toBe(updatedName); + expect(upserted.organizationId).toBe(ORG_A); + expect(upserted.name).toBe(upsertedName); + }); + + it("findMany returns only active tenant rows", async () => { + const aName = makeName("findmany-a"); + const bName = makeName("findmany-b"); + + await runInTenant(ORG_A, () => + scopedDb.company.create({ data: { name: aName } }), + ); + await runInTenant(ORG_B, () => + scopedDb.company.create({ data: { name: bName } }), + ); + + const scopedRows = await runInTenant(ORG_A, () => + scopedDb.company.findMany({ + where: { + name: { + in: [aName, bName], + }, + }, + }), + ); + + expect(scopedRows).toHaveLength(1); + expect(scopedRows[0]?.name).toBe(aName); + expect(scopedRows[0]?.organizationId).toBe(ORG_A); + }); + + it("findUnique cannot access another tenant row", async () => { + const bName = makeName("findunique-b"); + const bCompany = await runInTenant(ORG_B, () => + scopedDb.company.create({ data: { name: bName } }), + ); + + const tenantRow = await runInTenant(ORG_A, () => + scopedDb.company.findUnique({ + where: { id: bCompany.id }, + }), + ); + + expect(tenantRow).toBeNull(); + }); + + it("isolates count by tenant", async () => { + const sharedName = makeName("count"); + + const createdA = await runInTenant(ORG_A, () => + scopedDb.company.create({ data: { name: `${sharedName}-a` } }), + ); + const createdB = await runInTenant(ORG_B, () => + scopedDb.company.create({ data: { name: `${sharedName}-b` } }), + ); + + const aCount = await runInTenant(ORG_A, () => + scopedDb.company.count({ + where: { name: { in: [createdA.name, createdB.name] } }, + }), + ); + const bCount = await runInTenant(ORG_B, () => + scopedDb.company.count({ + where: { name: { in: [createdA.name, createdB.name] } }, + }), + ); + + expect(aCount).toBe(1); + expect(bCount).toBe(1); + }); + + it("rejects update and delete for rows outside the active tenant and keeps source rows unchanged", async () => { + const targetName = makeName("cross"); + const victim = await runInTenant(ORG_B, () => + scopedDb.company.create({ data: { name: targetName } }), + ); + + await expect( + Promise.resolve( + runInTenant(ORG_A, () => + scopedDb.company.update({ + where: { id: victim.id }, + data: { name: makeName("updated") }, + }), + ), + ), + ).rejects.toThrow(); + + await expect( + Promise.resolve( + runInTenant(ORG_A, () => + scopedDb.company.delete({ where: { id: victim.id } }), + ), + ), + ).rejects.toThrow(); + + const unchanged = await runInTenant(ORG_B, () => + scopedDb.company.findUnique({ where: { id: victim.id } }), + ); + + expect(unchanged?.organizationId).toBe(ORG_B); + expect(unchanged?.name).toBe(targetName); + }); + + it("defaults every row in createMany", async () => { + const base = makeName("createMany"); + const names = [`${base}-a`, `${base}-b`]; + + await runInTenant(ORG_A, () => + scopedDb.company.createMany({ + data: names.map((name) => ({ name })), + }), + ); + + const rows = await runInTenant(ORG_A, () => + scopedDb.company.findMany({ where: { name: { in: names } } }), + ); + + expect(rows).toHaveLength(2); + expect(rows.every((row) => row.organizationId === ORG_A)).toBe(true); + }); + + it("reads organization without tenant context", async () => { + const orgA = await scopedDb.organization.findUnique({ + where: { id: ORG_A }, + }); + const orgB = await scopedDb.organization.findUnique({ + where: { id: ORG_B }, + }); + + expect(orgA?.id).toBe(ORG_A); + expect(orgB?.id).toBe(ORG_B); + }); + + it("lets organizations reuse contact emails and company domains", async () => { + const email = `shared-${testPrefix}@example.com`; + const domain = `${testPrefix}.example.com`; + const [contactA, contactB, companyA, companyB] = await Promise.all([ + runInTenant(ORG_A, () => + scopedDb.contact.create({ data: { firstName: "A", email } }), + ), + runInTenant(ORG_B, () => + scopedDb.contact.create({ data: { firstName: "B", email } }), + ), + runInTenant(ORG_A, () => + scopedDb.company.create({ data: { name: "Acme A", domain } }), + ), + runInTenant(ORG_B, () => + scopedDb.company.create({ data: { name: "Acme B", domain } }), + ), + ]); + + expect(contactA.email).toBe(email); + expect(contactB.email).toBe(email); + expect(companyA.domain).toBe(domain); + expect(companyB.domain).toBe(domain); + }); + + it("refuses a duplicate contact email inside one organization", async () => { + const email = `duplicate-${testPrefix}@example.com`; + + await runInTenant(ORG_A, () => + scopedDb.contact.create({ data: { firstName: "First", email } }), + ); + + await expect( + runInTenant(ORG_A, () => + scopedDb.contact.create({ data: { firstName: "Second", email } }), + ), + ).rejects.toThrow(); + }); + + it("isolates representative tenant models", async () => { + const created = await runInTenant(ORG_A, async () => ({ + conversation: await scopedDb.agentConversation.create({ + data: { userId }, + }), + definition: await scopedDb.agentDefinition.create({ + data: { name: "Agent A", createdById: userId }, + }), + task: await scopedDb.agentTask.create({ + data: { kind: "brand", reason: "test", dueAt: new Date() }, + }), + mailbox: await scopedDb.mailboxSync.create({ + data: { userId, source: "gmail" }, + }), + })); + + await runInTenant(ORG_B, async () => { + await scopedDb.agentConversation.create({ data: { userId } }); + await scopedDb.agentDefinition.create({ + data: { name: "Agent B", createdById: userId }, + }); + await scopedDb.agentTask.create({ + data: { kind: "brand", reason: "test", dueAt: new Date() }, + }); + await scopedDb.mailboxSync.create({ data: { userId, source: "gmail" } }); + }); + + const seen = await runInTenant(ORG_A, async () => ({ + conversations: await scopedDb.agentConversation.findMany(), + definitions: await scopedDb.agentDefinition.findMany(), + tasks: await scopedDb.agentTask.findMany(), + mailboxes: await scopedDb.mailboxSync.findMany(), + })); + + expect(seen.conversations.map(({ id }) => id)).toContain( + created.conversation.id, + ); + expect(seen.definitions.map(({ id }) => id)).toContain( + created.definition.id, + ); + expect(seen.tasks.map(({ id }) => id)).toContain(created.task.id); + expect(seen.mailboxes.map(({ id }) => id)).toContain(created.mailbox.id); + expect(seen.conversations).toHaveLength(1); + expect(seen.definitions).toHaveLength(1); + expect(seen.tasks).toHaveLength(1); + expect(seen.mailboxes).toHaveLength(1); + }); + + it("isolates suppression and settings values", async () => { + const email = `suppressed-${testPrefix}@example.com`; + await Promise.all([ + runInTenant(ORG_A, () => + scopedDb.suppressedContact.create({ data: { email } }), + ), + runInTenant(ORG_A, () => + scopedDb.appSetting.create({ data: { contextDevApiKey: "key-a" } }), + ), + runInTenant(ORG_B, () => + scopedDb.appSetting.create({ data: { contextDevApiKey: "key-b" } }), + ), + ]); + + const [suppressedInA, suppressedInB, settingA, settingB] = + await Promise.all([ + runInTenant(ORG_A, () => + scopedDb.suppressedContact.findUnique({ + where: { organizationId_email: { organizationId: ORG_A, email } }, + }), + ), + runInTenant(ORG_B, () => + scopedDb.suppressedContact.findUnique({ + where: { organizationId_email: { organizationId: ORG_B, email } }, + }), + ), + runInTenant(ORG_A, () => + scopedDb.appSetting.findUnique({ where: { organizationId: ORG_A } }), + ), + runInTenant(ORG_B, () => + scopedDb.appSetting.findUnique({ where: { organizationId: ORG_B } }), + ), + ]); + + expect(suppressedInA).not.toBeNull(); + expect(suppressedInB).toBeNull(); + expect(settingA?.contextDevApiKey).toBe("key-a"); + expect(settingB?.contextDevApiKey).toBe("key-b"); + }); +}); + +describe("scoped transactions", () => { + it("throws TenantContextError without tenant context", async () => { + await expect(scopedTransaction(async () => undefined)).rejects.toThrow( + TenantContextError, + ); + }); + + it("sets the tenant before the callback starts", async () => { + const setting = await runInTenant(ORG_A, () => + scopedTransaction(async (tx) => { + const rows = await tx.$queryRaw>` + SELECT current_setting('app.current_organization_id', true) AS "organizationId" + `; + + return rows[0]?.organizationId; + }), + ); + + expect(setting).toBe(ORG_A); + }); + + it("uses an injected client and transaction options", async () => { + const result = await runInTenant(ORG_A, () => + scopedTransaction( + scopedDb as ScopedDb, + async (tx) => { + const rows = await tx.$queryRaw< + Array<{ organizationId: string }> + >`SELECT current_setting('app.current_organization_id', true) AS "organizationId"`; + const count = await tx.company.count(); + return { organizationId: rows[0]?.organizationId, count }; + }, + { timeout: 5_000 }, + ), + ); + + expect(result.organizationId).toBe(ORG_A); + expect(result.count).toBeGreaterThanOrEqual(0); + }); + + it("applies tenant defaults to every write", async () => { + const names = [ + makeName("transaction-first"), + makeName("transaction-second"), + ]; + + const rows = await runInTenant(ORG_A, () => + scopedTransaction(async (tx) => + Promise.all(names.map((name) => tx.company.create({ data: { name } }))), + ), + ); + + expect(rows).toHaveLength(2); + expect(rows.every((row) => row.organizationId === ORG_A)).toBe(true); + }); + + it("rolls back writes after a callback failure", async () => { + const name = makeName("transaction-rollback"); + + await expect( + runInTenant(ORG_A, () => + scopedTransaction(async (tx) => { + await tx.company.create({ data: { name } }); + throw new Error("rollback requested"); + }), + ), + ).rejects.toThrow("rollback requested"); + + const count = await runInTenant(ORG_A, () => + scopedDb.company.count({ where: { name } }), + ); + + expect(count).toBe(0); + }); + + it("reuses the active transaction for scopedDb calls", async () => { + const name = makeName("active-transaction"); + + await expect( + runInTenant(ORG_A, () => + scopedTransaction(async () => { + await scopedDb.company.create({ data: { name } }); + throw new Error("active transaction rollback"); + }), + ), + ).rejects.toThrow("active transaction rollback"); + + const count = await runInTenant(ORG_A, () => + scopedDb.company.count({ where: { name } }), + ); + + expect(count).toBe(0); + }); + + it("clears the tenant setting after commit", async () => { + await runInTenant(ORG_A, () => scopedTransaction(async () => undefined)); + + const setting = await db.$transaction(async (tx) => { + const rows = await tx.$queryRaw>` + SELECT current_setting('app.current_organization_id', true) AS "organizationId" + `; + + return rows[0]?.organizationId; + }); + + expect(setting).not.toBe(ORG_A); + }); +}); diff --git a/packages/db/src/tenant-scope.ts b/packages/db/src/tenant-scope.ts new file mode 100644 index 000000000..65a0907a7 --- /dev/null +++ b/packages/db/src/tenant-scope.ts @@ -0,0 +1,156 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { Db } from "./client"; +import { db } from "./client"; +import type { Prisma } from "./generated/prisma/client"; +import { currentOrganizationId, runInTenant } from "./tenant-context"; + +const tenantScopedModels = new Set([ + "SlackMemberMatch", + "SlackChannel", + "SlackInstallation", + "SlackWorkspaceGrant", + "Company", + "CompanyEnrichment", + "Contact", + "ContactFact", + "ContactBrief", + "AgentTask", + "AgentEvent", + "AgentConversation", + "AgentConversationFeedback", + "AgentConversationShare", + "AgentConversationSubmission", + "AgentConversationAttachment", + "AgentDefinition", + "AgentVersion", + "AgentBuilderArtifact", + "AgentTrigger", + "AgentRun", + "AgentRunEvent", + "AgentAction", + "AgentAuditEvent", + "Deal", + "DealContact", + "FieldDefinition", + "FieldOption", + "FieldValue", + "SavedView", + "Activity", + "AppointmentDetails", + "MailboxSync", + "EmailThread", + "EmailMessage", + "CalendarEvent", + "CalendarAttendee", + "SuppressedDomain", + "SuppressedContact", + "AppSetting", + "TrackedDomain", + "TrackedVisitor", + "TrackedEvent", + "TrackingCounter", + "TrackedPageDaily", + "FormSubmission", + "WorkspaceProfile", + "SsoProvider", + "Artifact", + "AssetUpload", + "AssetEmailSource", + "AssetStorageJob", + "AssetApiRequest", +]); + +const activeTransactionStorage = + new AsyncLocalStorage(); + +export const scopedDb = db.$extends({ + name: "tenant-scope", + query: { + $allModels: { + async $allOperations({ model, operation, args, query }) { + if (!model || !tenantScopedModels.has(model as Prisma.ModelName)) { + return query(args); + } + + const activeTransaction = activeTransactionStorage.getStore(); + const executeOperation = (client: Prisma.TransactionClient) => { + const key = model.charAt(0).toLowerCase() + model.slice(1); + const delegate = Reflect.get(client, key); + const action = Reflect.get(delegate, operation); + return Reflect.apply(action, delegate, [args]); + }; + + if (activeTransaction) { + return executeOperation(activeTransaction); + } + + return scopedTransaction(async (tx) => executeOperation(tx)); + }, + }, + }, +}); + +export type ScopedDb = Db; + +export interface ScopedTransactionOptions { + isolationLevel?: Prisma.TransactionIsolationLevel; + maxWait?: number; + timeout?: number; +} + +type ScopedTransactionWork = (tx: Prisma.TransactionClient) => Promise; +interface TransactionRunner { + $transaction( + work: ScopedTransactionWork, + options?: ScopedTransactionOptions, + ): Promise; +} +type TransactionClientProvider = Db; + +export function scopedTransaction( + fn: ScopedTransactionWork, + options?: ScopedTransactionOptions, +): Promise; +export function scopedTransaction( + client: TransactionClientProvider, + fn: ScopedTransactionWork, + options?: ScopedTransactionOptions, +): Promise; +export async function scopedTransaction( + clientOrWork: TransactionClientProvider | ScopedTransactionWork, + workOrOptions?: ScopedTransactionWork | ScopedTransactionOptions, + transactionOptions?: ScopedTransactionOptions, +): Promise { + const client = clientOrWork instanceof Function ? db : clientOrWork; + const work = + clientOrWork instanceof Function + ? clientOrWork + : (workOrOptions as ScopedTransactionWork); + const options = + clientOrWork instanceof Function + ? (workOrOptions as ScopedTransactionOptions | undefined) + : transactionOptions; + const organizationId = currentOrganizationId(); + const transactionRunner = client === scopedDb ? db : client; + + return (transactionRunner as TransactionRunner).$transaction(async (tx) => { + await setTenant(tx, organizationId); + return activeTransactionStorage.run(tx, () => work(tx)); + }, options); +} + +export function tenantTransaction( + organizationId: string, + action: (tx: Prisma.TransactionClient) => Promise, +): Promise { + return runInTenant(organizationId, () => scopedTransaction(action)); +} + +function setTenant( + tx: Prisma.TransactionClient, + organizationId: string, +): Promise { + return tx.$queryRaw` + SELECT set_config('app.current_organization_id', ${organizationId}, true) + `; +} diff --git a/packages/db/src/tenants.ts b/packages/db/src/tenants.ts new file mode 100644 index 000000000..02313c9dc --- /dev/null +++ b/packages/db/src/tenants.ts @@ -0,0 +1,57 @@ +import type { Db } from "./client"; +import { db } from "./client"; +import type { Prisma } from "./generated/prisma/client"; +import { runLimited } from "./pool"; +import { runInTenant } from "./tenant-context"; +import { tenantTransaction } from "./tenant-scope"; + +type OrganizationReader = Pick; + +export async function organizationIds( + client: OrganizationReader = db, +): Promise { + const organizations = await client.organization.findMany({ + orderBy: { id: "asc" }, + select: { id: true }, + }); + return organizations.map(({ id }) => id); +} + +export interface ForEachTenantOptions { + concurrency?: number; + organizations?: readonly string[]; +} + +export async function forEachTenant( + work: (organizationId: string, tx: Prisma.TransactionClient) => Promise, + options: ForEachTenantOptions = {}, +): Promise { + const organizations = options.organizations ?? (await organizationIds()); + await runLimited( + options.concurrency ?? organizations.length, + organizations, + (organizationId) => + tenantTransaction(organizationId, (tx) => work(organizationId, tx)), + ); +} + +export async function collectAcrossTenants( + query: (organizationId: string) => Promise, + options: ForEachTenantOptions = {}, +): Promise { + const rows: T[] = []; + await forEachTenant(async (organizationId) => { + rows.push(...(await query(organizationId))); + }, options); + return rows; +} + +export async function locateTenantRow( + query: (organizationId: string) => Promise, +): Promise<{ organizationId: string; row: T } | null> { + for (const organizationId of await organizationIds()) { + const row = await runInTenant(organizationId, () => query(organizationId)); + if (row) return { organizationId, row }; + } + return null; +} diff --git a/packages/db/src/test-support.ts b/packages/db/src/test-support.ts new file mode 100644 index 000000000..16cd49b28 --- /dev/null +++ b/packages/db/src/test-support.ts @@ -0,0 +1,56 @@ +import { + afterAll as bunAfterAll, + afterEach as bunAfterEach, + beforeAll as bunBeforeAll, + beforeEach as bunBeforeEach, + it as bunIt, +} from "bun:test"; +import { runInTenant } from "./tenant-context"; + +type TestBody = () => void | Promise; + +export function tenantTest(organizationId: string) { + return (name: string, test: TestBody): void => { + bunIt(name, () => runInTenant(organizationId, test)); + }; +} + +function tenantHook(register: (hook: TestBody) => void) { + return (organizationId: string) => + (hook: TestBody): void => { + register(() => runInTenant(organizationId, hook)); + }; +} + +export const tenantBeforeAll = tenantHook(bunBeforeAll); +export const tenantBeforeEach = tenantHook(bunBeforeEach); +export const tenantAfterEach = tenantHook(bunAfterEach); +export const tenantAfterAll = tenantHook(bunAfterAll); + +export function tenantContext(organizationId: string) { + return async (work: () => T | PromiseLike): Promise => + await runInTenant(organizationId, work); +} + +export async function createTenantRows( + rows: T[], + create: (row: T) => PromiseLike, +): Promise { + await Promise.all( + rows.map((row) => runInTenant(row.organizationId, () => create(row))), + ); +} + +export function tenantBound( + organizationId: string, + service: T, +): T { + return new Proxy(service, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (!(value instanceof Function)) return value; + return (...args: unknown[]) => + runInTenant(organizationId, () => value.apply(target, args)); + }, + }); +} diff --git a/packages/db/src/tracking-tenant-scope.test.ts b/packages/db/src/tracking-tenant-scope.test.ts new file mode 100644 index 000000000..d9023c993 --- /dev/null +++ b/packages/db/src/tracking-tenant-scope.test.ts @@ -0,0 +1,44 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "./client"; +import { runInTenant } from "./tenant-context"; +import { scopedDb } from "./tenant-scope"; + +const ORG_A = "test-track-org-a"; +const ORG_B = "test-track-org-b"; + +beforeAll(async () => { + await db.organization.createMany({ + data: [ + { id: ORG_A, name: "Org A", slug: "track-org-a", createdAt: new Date() }, + { id: ORG_B, name: "Org B", slug: "track-org-b", createdAt: new Date() }, + ], + skipDuplicates: true, + }); +}); + +afterAll(async () => { + for (const organizationId of [ORG_A, ORG_B]) { + await runInTenant(organizationId, () => + scopedDb.trackedDomain.deleteMany(), + ); + } + await db.organization.deleteMany({ + where: { id: { in: [ORG_A, ORG_B] } }, + }); +}); + +describe("tracking tenant scoping", () => { + it("lets two tenants each track the same host", async () => { + const host = "shared-client-site.com"; + + const a = await runInTenant(ORG_A, () => + scopedDb.trackedDomain.create({ data: { host } }), + ); + const b = await runInTenant(ORG_B, () => + scopedDb.trackedDomain.create({ data: { host } }), + ); + + expect(a.host).toBe(host); + expect(b.host).toBe(host); + }); +}); diff --git a/packages/db/src/tracking.ts b/packages/db/src/tracking.ts index dc3c6fea2..adb8b42ca 100644 --- a/packages/db/src/tracking.ts +++ b/packages/db/src/tracking.ts @@ -1,6 +1,6 @@ import { createHash, randomBytes } from "node:crypto"; import type { Db } from "./client"; -import { SETTINGS_ID } from "./settings"; +import { currentOrganizationId } from "./tenant-context"; export const SITE_ID_PREFIX = "cmp_"; @@ -256,7 +256,7 @@ export async function readTrackingConfig( ): Promise { const [settings, domains] = await Promise.all([ db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, + where: { organizationId: currentOrganizationId() }, select: { trackingSiteId: true, trackingCrossDomain: true, diff --git a/packages/db/src/workspace-slug.ts b/packages/db/src/workspace-slug.ts new file mode 100644 index 000000000..63a93e42c --- /dev/null +++ b/packages/db/src/workspace-slug.ts @@ -0,0 +1,33 @@ +export const DEFAULT_WORKSPACE_SLUG = "workspace"; + +export const MAX_SLUG = 48; + +export const RESERVED_SLUGS: readonly string[] = [ + "_next", + "api", + "agent", + "agents", + "chat", + "companies", + "contacts", + "deals", + "eve", + "grant-access", + "onboarding", + "settings", + "sign-in", +]; + +export function workspaceSlug(name: string): string { + const base = name + .normalize("NFKD") + .replace(/\p{M}/gu, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .slice(0, MAX_SLUG) + .replace(/^-+|-+$/g, ""); + + if (!base) return DEFAULT_WORKSPACE_SLUG; + + return RESERVED_SLUGS.includes(base) ? `${base}-crm` : base; +} diff --git a/packages/db/src/workspace.test.ts b/packages/db/src/workspace.test.ts new file mode 100644 index 000000000..b28268e81 --- /dev/null +++ b/packages/db/src/workspace.test.ts @@ -0,0 +1,119 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "./client"; +import type { Prisma } from "./generated/prisma/client"; +import { runInTenant, TenantContextError } from "./tenant-context"; +import { scopedTransaction } from "./tenant-scope"; +import { + readWorkspaceIdentity, + readWorkspaceProfile, + writeWorkspaceProfile, +} from "./workspace"; + +const testPrefix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; +const ORG_A = `workspacefn-org-a-${testPrefix}`; +const ORG_B = `workspacefn-org-b-${testPrefix}`; + +const inTenant = ( + organizationId: string, + work: (tx: Prisma.TransactionClient) => Promise, +) => runInTenant(organizationId, () => scopedTransaction(work)); + +beforeAll(async () => { + await db.organization.createMany({ + data: [ + { + id: ORG_A, + name: `Org A ${testPrefix}`, + slug: `workspacefn-org-a-${testPrefix}`, + website: "https://a.example.com", + createdAt: new Date(), + }, + { + id: ORG_B, + name: `Org B ${testPrefix}`, + slug: `workspacefn-org-b-${testPrefix}`, + website: "https://b.example.com", + createdAt: new Date(), + }, + ], + skipDuplicates: true, + }); +}); + +afterAll(async () => { + for (const organizationId of [ORG_A, ORG_B]) { + await inTenant(organizationId, (tx) => tx.workspaceProfile.deleteMany()); + } + await db.organization.deleteMany({ + where: { id: { in: [ORG_A, ORG_B] } }, + }); +}); + +describe("workspace readers/writers outside any tenant context", () => { + it("throw TenantContextError rather than reading anyone's profile", async () => { + await expect(readWorkspaceProfile(db)).rejects.toThrow(TenantContextError); + await expect(readWorkspaceIdentity(db)).rejects.toThrow(TenantContextError); + }); +}); + +describe("workspace readers/writers inside tenant context", () => { + it("keeps the workspace profile isolated per organization", async () => { + await inTenant(ORG_A, (tx) => + writeWorkspaceProfile(tx, { + website: "https://a.example.com", + narrative: "Sells compliance automation to mid-market SaaS.", + sections: { + sells: "Compliance automation", + sellsTo: "Mid-market SaaS", + }, + }), + ); + + const inA = await inTenant(ORG_A, (tx) => readWorkspaceProfile(tx)); + const inB = await inTenant(ORG_B, (tx) => readWorkspaceProfile(tx)); + + expect(inA?.narrative).toBe( + "Sells compliance automation to mid-market SaaS.", + ); + expect(inB).toBeNull(); + }); + + it("resolves readWorkspaceIdentity to the active organization, not a fixed one", async () => { + const identityA = await inTenant(ORG_A, (tx) => readWorkspaceIdentity(tx)); + const identityB = await inTenant(ORG_B, (tx) => readWorkspaceIdentity(tx)); + + expect(identityA?.name).toBe(`Org A ${testPrefix}`); + expect(identityA?.website).toBe("https://a.example.com"); + expect(identityB?.name).toBe(`Org B ${testPrefix}`); + expect(identityB?.website).toBe("https://b.example.com"); + }); + + it("keeps stale profile handling tenant-local", async () => { + await inTenant(ORG_A, (tx) => + writeWorkspaceProfile(tx, { + website: "https://stale-a.example.com", + narrative: "Legacy profile URL", + sections: { sells: "Legacy sells", sellsTo: "Legacy sells-to" }, + }), + ); + await inTenant(ORG_B, (tx) => + writeWorkspaceProfile(tx, { + website: "https://b.example.com", + narrative: "Active profile URL", + sections: { sells: "Compliance", sellsTo: "Small teams" }, + }), + ); + + await db.organization.update({ + where: { id: ORG_A }, + data: { website: "https://renamed-a.example.com" }, + }); + + const inA = await inTenant(ORG_A, (tx) => readWorkspaceIdentity(tx)); + const inB = await inTenant(ORG_B, (tx) => readWorkspaceIdentity(tx)); + + expect(inA?.profile).toBeNull(); + expect(inB?.profile).not.toBeNull(); + expect(inB?.profile?.website).toBe("https://b.example.com"); + }); +}); diff --git a/packages/db/src/workspace.ts b/packages/db/src/workspace.ts index b49c748c3..323765242 100644 --- a/packages/db/src/workspace.ts +++ b/packages/db/src/workspace.ts @@ -6,42 +6,11 @@ import { jsonText, type WorkspaceProfileSections, } from "./json"; +import { currentOrganizationId } from "./tenant-context"; -export const WORKSPACE_ID = "workspace"; +type WorkspaceDb = Pick; -export const DEFAULT_WORKSPACE_SLUG = "workspace"; - -export const MAX_SLUG = 48; - -export const RESERVED_SLUGS: readonly string[] = [ - "_next", - "api", - "agent", - "agents", - "chat", - "companies", - "contacts", - "deals", - "eve", - "grant-access", - "onboarding", - "settings", - "sign-in", -]; - -export function workspaceSlug(name: string): string { - const base = name - .normalize("NFKD") - .replace(/\p{M}/gu, "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .slice(0, MAX_SLUG) - .replace(/^-+|-+$/g, ""); - - if (!base) return DEFAULT_WORKSPACE_SLUG; - - return RESERVED_SLUGS.includes(base) ? `${base}-crm` : base; -} +export const WORKSPACE_ID = "workspace"; export const MAX_NARRATIVE = 320; @@ -88,10 +57,10 @@ export type WorkspaceIdentity = { }; export async function readWorkspaceProfile( - db: Db, + db: WorkspaceDb, ): Promise { const row = await db.workspaceProfile.findUnique({ - where: { id: WORKSPACE_ID }, + where: { organizationId: currentOrganizationId() }, select: { website: true, narrative: true, @@ -137,11 +106,12 @@ export function profileOf( } export async function readWorkspaceIdentity( - db: Db, + db: WorkspaceDb, ): Promise { + const organizationId = currentOrganizationId(); const [workspace, profile] = await Promise.all([ db.organization.findUnique({ - where: { id: WORKSPACE_ID }, + where: { id: organizationId }, select: { name: true, website: true }, }), readWorkspaceProfile(db), @@ -157,7 +127,7 @@ export async function readWorkspaceIdentity( } export async function writeWorkspaceProfile( - db: Db, + db: WorkspaceDb, input: { website: string; narrative: string; @@ -166,6 +136,7 @@ export async function writeWorkspaceProfile( sessionId?: string | null; }, ): Promise { + const organizationId = currentOrganizationId(); const fields = { website: input.website, narrative: clamp(input.narrative, MAX_NARRATIVE) ?? "", @@ -176,8 +147,8 @@ export async function writeWorkspaceProfile( }; const row = await db.workspaceProfile.upsert({ - where: { id: WORKSPACE_ID }, - create: { id: WORKSPACE_ID, ...fields }, + where: { organizationId }, + create: fields, update: fields, select: { website: true, diff --git a/packages/db/test/workspace-slug.spec.ts b/packages/db/test/workspace-slug.spec.ts index 941e10405..34d5ff3a3 100644 --- a/packages/db/test/workspace-slug.spec.ts +++ b/packages/db/test/workspace-slug.spec.ts @@ -4,7 +4,7 @@ import { MAX_SLUG, RESERVED_SLUGS, workspaceSlug, -} from "../src/workspace"; +} from "../src/workspace-slug"; describe("workspaceSlug", () => { it("is the company name a rep can read in the address bar", () => { diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index df18351ab..8d23b8a8a 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "@crm/typescript-config/internal-package.json", "compilerOptions": { - "types": ["node"] + "types": ["node", "bun"] }, "include": ["src/**/*.ts", "prisma/**/*.ts", "prisma.config.ts"], "exclude": ["node_modules"] diff --git a/packages/db/turbo.json b/packages/db/turbo.json index 76cf20afe..6211c5c43 100644 --- a/packages/db/turbo.json +++ b/packages/db/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://turborepo.dev/schema.json", + "$schema": "https://v2-10-12.turborepo.dev/schema.json", "extends": ["//"], "tasks": { "build": { diff --git a/packages/env/package.json b/packages/env/package.json index 9bf9c77f0..9347e63de 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/node": "^24.10.1", - "typescript": "5.9.2" + "@types/node": "^26.5.0", + "typescript": "7.0.2" } } diff --git a/packages/env/test/root.spec.ts b/packages/env/test/root.spec.ts index 460903046..092e7b76c 100644 --- a/packages/env/test/root.spec.ts +++ b/packages/env/test/root.spec.ts @@ -80,3 +80,13 @@ describe("the committed .env.example", () => { } }); }); + +describe("the app build environment", () => { + it("inherits the root build variables", () => { + const config = JSON.parse( + readFileSync(join(repoRoot, "apps", "app", "turbo.json"), "utf8"), + ); + + expect(config.tasks.build.env).toContain("$TURBO_EXTENDS$"); + }); +}); diff --git a/packages/kaneo-domain/kaneo.prisma b/packages/kaneo-domain/kaneo.prisma new file mode 100644 index 000000000..efc435b74 --- /dev/null +++ b/packages/kaneo-domain/kaneo.prisma @@ -0,0 +1,521 @@ +model UserAvatar { + id String @id @default(cuid()) + userId String @map("user_id") @unique(map: "user_avatar_user_id_unique") + mimeType String @map("mime_type") + size Int + data Bytes + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@index([userId], map: "user_avatar_userId_idx") + @@map("user_avatar") +} + +model Workspace { + id String @id @default(cuid()) + name String + slug String @unique + logo String? + metadata String? + description String? + createdAt DateTime @map("created_at") + workspaceMembers WorkspaceMember[] + workspaceBillings WorkspaceBilling[] + teams Team[] + workspaceInvitations WorkspaceInvitation[] + workspaceRoles WorkspaceRole[] + projects Project[] + billingReminderSents BillingReminderSent[] + assets Asset[] + labels Label[] + userNotificationWorkspaceRules UserNotificationWorkspaceRule[] + userNotificationWorkspaceProjects UserNotificationWorkspaceProject[] + @@map("workspace") +} + +model WorkspaceMember { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + userId String @map("user_id") + role String @default("member") + joinedAt DateTime @map("joined_at") + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + @@index([workspaceId], map: "workspace_member_workspaceId_idx") + @@index([userId], map: "workspace_member_userId_idx") + @@map("workspace_member") +} + +model WorkspaceBilling { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") @unique(map: "workspace_billing_workspace_id_unique") + foundingFree Boolean @map("founding_free") @default(false) + trialEndsAt DateTime? @map("trial_ends_at") + creemCustomerId String? @map("creem_customer_id") + creemSubscriptionId String? @map("creem_subscription_id") @unique + creemProductId String? @map("creem_product_id") + plan String? + billingInterval String? @map("billing_interval") + status String? + seats Int @default(1) + currentPeriodEnd DateTime? @map("current_period_end") + canceledAt DateTime? @map("canceled_at") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([workspaceId], map: "workspace_billing_workspaceId_idx") + @@map("workspace_billing") +} + +model TrialGrant { + emailHash String @map("email_hash") @id + trialEndsAt DateTime @map("trial_ends_at") + createdAt DateTime @map("created_at") @default(now()) + @@map("trial_grant") +} + +model BillingEvent { + id String @id + eventType String @map("event_type") + processedAt DateTime @map("processed_at") @default(now()) + @@map("billing_event") +} + +model Team { + id String @id + name String + workspaceId String @map("workspace_id") + createdAt DateTime @map("created_at") + updatedAt DateTime? @map("updated_at") @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + teamMembers TeamMember[] + @@index([workspaceId], map: "team_workspaceId_idx") + @@map("team") +} + +model TeamMember { + id String @id + teamId String @map("team_id") + userId String @map("user_id") + createdAt DateTime? @map("created_at") + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + @@index([teamId], map: "teamMember_teamId_idx") + @@index([userId], map: "teamMember_userId_idx") + @@map("team_member") +} + +model WorkspaceInvitation { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + email String + role String? + teamId String? @map("team_id") + status String @default("pending") + expiresAt DateTime @map("expires_at") + createdAt DateTime @map("created_at") @default(now()) + inviterId String @map("inviter_id") + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + @@index([workspaceId], map: "workspace_invitation_workspaceId_idx") + @@index([email], map: "workspace_invitation_email_idx") + @@index([inviterId], map: "workspace_invitation_inviterId_idx") + @@map("workspace_invitation") +} + +model WorkspaceRole { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + role String + permission String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([workspaceId], map: "workspace_role_workspaceId_idx") + @@index([role], map: "workspace_role_role_idx") + @@map("workspace_role") +} + +model Project { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + slug String + icon String? @default("Layout") + name String + description String? + createdAt DateTime @map("created_at") @default(now()) + isPublic Boolean? @map("is_public") @default(false) + archivedAt DateTime? @map("archived_at") + lastTaskNumber Int @map("last_task_number") @default(0) + position Int @default(0) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectColumns ProjectColumn[] + workflowRules WorkflowRule[] + projectTasks ProjectTask[] + assets Asset[] + userNotificationWorkspaceProjects UserNotificationWorkspaceProject[] + githubIntegrations GithubIntegration[] + integrations Integration[] + @@unique([workspaceId, id], map: "project_workspace_id_id_unique") + @@index([workspaceId, position], map: "project_workspaceId_position_idx") + @@map("project") +} + +model ProjectColumn { + id String @id @default(cuid()) + projectId String @map("project_id") + name String + slug String + position Int @default(0) + icon String? + color String? + isFinal Boolean @map("is_final") @default(false) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + workflowRules WorkflowRule[] + projectTasks ProjectTask[] + @@index([projectId], map: "column_projectId_idx") + @@map("column") +} + +model WorkflowRule { + id String @id @default(cuid()) + projectId String @map("project_id") + integrationType String @map("integration_type") + eventType String @map("event_type") + columnId String @map("column_id") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + column ProjectColumn @relation(fields: [columnId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([projectId], map: "workflow_rule_projectId_idx") + @@index([columnId], map: "workflow_rule_columnId_idx") + @@map("workflow_rule") +} + +model ProjectTask { + id String @id @default(cuid()) + projectId String @map("project_id") + position Int? @default(0) + number Int? @default(1) + userId String? @map("assignee_id") + title String + description String? + status String @default("to-do") + columnId String? @map("column_id") + priority String @default("low") + startDate DateTime? @map("start_date") + dueDate DateTime? @map("due_date") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + column ProjectColumn? @relation(fields: [columnId], references: [id], onDelete: SetNull, onUpdate: Cascade) + taskReminderSents TaskReminderSent[] + timeEntries TimeEntry[] + taskActivities TaskActivity[] + assets Asset[] + labels Label[] + externalLinks ExternalLink[] + taskComments TaskComment[] + taskRelations TaskRelation[] + taskRelations2 TaskRelation[] @relation("TaskRelationToProjectTask_1") + @@unique([projectId, number], map: "task_project_number_unique") + @@index([projectId], map: "task_projectId_idx") + @@index([dueDate], map: "task_dueDate_idx") + @@index([userId], map: "task_assigneeId_idx") + @@index([columnId], map: "task_columnId_idx") + @@map("task") +} + +model BillingReminderSent { + id String @id @default(cuid()) + userId String @map("user_id") + workspaceId String @map("workspace_id") + reminderType String @map("reminder_type") + trialEndsAt DateTime? @map("trial_ends_at") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@unique([userId, reminderType], map: "billing_reminder_sent_user_type_unique") + @@index([workspaceId], map: "billing_reminder_sent_workspaceId_idx") + @@index([userId], map: "billing_reminder_sent_userId_idx") + @@map("billing_reminder_sent") +} + +model JobLease { + name String @id + owner String + expiresAt DateTime @map("expires_at") + @@map("job_lease") +} + +model TaskReminderSent { + id String @id @default(cuid()) + taskId String @map("task_id") + reminderType String @map("reminder_type") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@unique([taskId, reminderType], map: "task_reminder_sent_task_type_unique") + @@index([taskId], map: "task_reminder_sent_taskId_idx") + @@map("task_reminder_sent") +} + +model TimeEntry { + id String @id @default(cuid()) + taskId String @map("task_id") + userId String? @map("user_id") + description String? + startTime DateTime @map("start_time") + endTime DateTime? @map("end_time") + duration Int? @default(0) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([taskId], map: "time_entry_taskId_idx") + @@index([userId], map: "time_entry_userId_idx") + @@map("time_entry") +} + +model TaskActivity { + id String @id @default(cuid()) + taskId String @map("task_id") + type String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + userId String? @map("user_id") + content String? + eventData Json? @map("event_data") + externalUserName String? @map("external_user_name") + externalUserAvatar String? @map("external_user_avatar") + externalSource String? @map("external_source") + externalUrl String? @map("external_url") + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + assets Asset[] + @@unique([taskId, externalSource, externalUrl], map: "activity_task_external_source_external_url_unique") + @@index([taskId], map: "activity_task_id_idx") + @@index([userId], map: "activity_userId_idx") + @@map("task_activity") +} + +model Asset { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + projectId String @map("project_id") + taskId String? @map("task_id") + activityId String? @map("activity_id") + objectKey String @map("object_key") @unique + filename String + mimeType String @map("mime_type") + size Int + kind String @default("image") + surface String @default("description") + createdBy String? @map("created_by") + createdAt DateTime @map("created_at") @default(now()) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + task ProjectTask? @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + activity TaskActivity? @relation(fields: [activityId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([workspaceId], map: "asset_workspaceId_idx") + @@index([projectId], map: "asset_projectId_idx") + @@index([taskId], map: "asset_taskId_idx") + @@index([activityId], map: "asset_activityId_idx") + @@index([createdBy], map: "asset_createdBy_idx") + @@map("asset") +} + +model Label { + id String @id @default(cuid()) + name String + color String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + taskId String? @map("task_id") + workspaceId String? @map("workspace_id") + task ProjectTask? @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@unique([taskId, name], map: "label_task_name_unique") + @@index([taskId], map: "label_task_id_idx") + @@index([workspaceId], map: "label_workspace_id_idx") + @@map("label") +} + +model Notification { + id String @id @default(cuid()) + userId String @map("user_id") + title String? + content String? + type String @default("info") + eventData Json? @map("event_data") + isRead Boolean? @map("is_read") @default(false) + resourceId String? @map("resource_id") + resourceType String? @map("resource_type") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@index([userId], map: "notification_userId_idx") + @@map("notification") +} + +model UserNotificationPreference { + id String @id @default(cuid()) + userId String @map("user_id") @unique + emailEnabled Boolean @map("email_enabled") @default(false) + ntfyEnabled Boolean @map("ntfy_enabled") @default(false) + ntfyServerUrl String? @map("ntfy_server_url") + ntfyTopic String? @map("ntfy_topic") + ntfyToken String? @map("ntfy_token") + gotifyEnabled Boolean @map("gotify_enabled") @default(false) + gotifyServerUrl String? @map("gotify_server_url") + gotifyToken String? @map("gotify_token") + webhookEnabled Boolean @map("webhook_enabled") @default(false) + webhookUrl String? @map("webhook_url") + webhookSecret String? @map("webhook_secret") + taskAssignmentEnabled Boolean @map("task_assignment_enabled") @default(true) + taskCommentEnabled Boolean @map("task_comment_enabled") @default(true) + taskStatusChangeEnabled Boolean @map("task_status_change_enabled") @default(true) + dueDateReminderEnabled Boolean @map("due_date_reminder_enabled") @default(true) + dueDateReminderLeadTimeMinutes Int @map("due_date_reminder_lead_time_minutes") @default(1440) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@map("user_notification_preference") +} + +model UserNotificationWorkspaceRule { + id String @id @default(cuid()) + userId String @map("user_id") + workspaceId String @map("workspace_id") + isActive Boolean @map("is_active") @default(true) + emailEnabled Boolean @map("email_enabled") @default(false) + ntfyEnabled Boolean @map("ntfy_enabled") @default(false) + gotifyEnabled Boolean @map("gotify_enabled") @default(false) + webhookEnabled Boolean @map("webhook_enabled") @default(false) + projectMode String @map("project_mode") @default("all") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userNotificationWorkspaceProjects UserNotificationWorkspaceProject[] + @@unique([userId, workspaceId], map: "user_notification_workspace_rule_user_workspace_unique") + @@unique([workspaceId, id], map: "user_notification_workspace_rule_workspace_id_id_unique") + @@index([userId], map: "user_notification_workspace_rule_userId_idx") + @@index([workspaceId], map: "user_notification_workspace_rule_workspaceId_idx") + @@map("user_notification_workspace_rule") +} + +model UserNotificationWorkspaceProject { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + workspaceRuleId String @map("workspace_rule_id") + projectId String @map("project_id") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userNotificationWorkspaceRule UserNotificationWorkspaceRule @relation(fields: [workspaceId, workspaceRuleId], references: [workspaceId, id], onDelete: Cascade, onUpdate: Cascade) + project Project @relation(fields: [workspaceId, projectId], references: [workspaceId, id], onDelete: Cascade, onUpdate: Cascade) + @@unique([workspaceRuleId, projectId], map: "user_notification_workspace_project_rule_project_unique") + @@index([workspaceRuleId], map: "user_notification_workspace_project_ruleId_idx") + @@index([projectId], map: "user_notification_workspace_project_projectId_idx") + @@index([workspaceId, projectId], map: "user_notification_workspace_project_workspaceId_projectId_idx") + @@index([workspaceId, workspaceRuleId], map: "unwp_workspaceId_workspaceRuleId_idx") + @@map("user_notification_workspace_project") +} + +model GithubIntegration { + id String @id @default(cuid()) + projectId String @map("project_id") @unique + repositoryOwner String @map("repository_owner") + repositoryName String @map("repository_name") + installationId Int? @map("installation_id") + isActive Boolean? @map("is_active") @default(true) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@map("github_integration") +} + +model Integration { + id String @id @default(cuid()) + projectId String @map("project_id") + type String + config String + isActive Boolean? @map("is_active") @default(true) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + externalLinks ExternalLink[] + @@unique([projectId, type], map: "integration_project_type_unique") + @@index([projectId], map: "integration_projectId_idx") + @@index([type], map: "integration_type_idx") + @@map("integration") +} + +model ExternalLink { + id String @id @default(cuid()) + taskId String @map("task_id") + integrationId String @map("integration_id") + resourceType String @map("resource_type") + externalId String @map("external_id") + url String + title String? + metadata String? + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([taskId], map: "external_link_taskId_idx") + @@index([integrationId], map: "external_link_integrationId_idx") + @@index([externalId], map: "external_link_externalId_idx") + @@index([resourceType], map: "external_link_resourceType_idx") + @@map("external_link") +} + +model TaskComment { + id String @id @default(cuid()) + taskId String @map("task_id") + userId String @map("user_id") + content String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([taskId], map: "comment_task_idx") + @@index([userId], map: "comment_user_idx") + @@map("comment") +} + +model TaskRelation { + id String @id @default(cuid()) + sourceTaskId String @map("source_task_id") + targetTaskId String @map("target_task_id") + relationType String @map("relation_type") + createdAt DateTime @map("created_at") @default(now()) + sourceTask ProjectTask @relation(fields: [sourceTaskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + targetTask ProjectTask @relation("TaskRelationToProjectTask_1", fields: [targetTaskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([sourceTaskId], map: "task_relation_source_idx") + @@index([targetTaskId], map: "task_relation_target_idx") + @@map("task_relation") +} + +model DeviceCode { + id String @id @default(cuid()) + deviceCode String @map("device_code") + userCode String @map("user_code") + userId String? @map("user_id") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + expiresAt DateTime @map("expires_at") + status String + lastPolledAt DateTime? @map("last_polled_at") + pollingInterval Int? @map("polling_interval") + clientId String? @map("client_id") + scope String? + @@index([userId], map: "device_code_user_id_idx") + @@map("device_code") +} + +model McpOauthState { + id String @id @default(cuid()) + kind String + key String + payload Json + expiresAt DateTime @map("expires_at") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@index([expiresAt], map: "mcp_oauth_state_expiresAt_idx") + @@map("mcp_oauth_state") +} \ No newline at end of file diff --git a/packages/kaneo-domain/package.json b/packages/kaneo-domain/package.json new file mode 100644 index 000000000..977b56079 --- /dev/null +++ b/packages/kaneo-domain/package.json @@ -0,0 +1,24 @@ +{ + "name": "@crm/kaneo-domain", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./drizzle": "./src/drizzle.ts" + }, + "scripts": { + "check-types": "tsc --noEmit", + "lint": "biome check .", + "test": "bun test", + "generate:prisma": "bun scripts/generate-prisma.ts", + "clean": "rm -rf .turbo node_modules" + }, + "devDependencies": { + "@crm/typescript-config": "workspace:*", + "@paralleldrive/cuid2": "^3.3.0", + "@types/node": "^26.5.0", + "drizzle-orm": "^0.45.2", + "typescript": "7.0.2" + } +} diff --git a/packages/kaneo-domain/scripts/generate-prisma.ts b/packages/kaneo-domain/scripts/generate-prisma.ts new file mode 100644 index 000000000..8f64cd999 --- /dev/null +++ b/packages/kaneo-domain/scripts/generate-prisma.ts @@ -0,0 +1,15 @@ +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import { + DEFAULT_EXCLUDE, + DEFAULT_RENAME, + kaneoSchema, + toPrismaFragment, +} from "../src/index"; + +const fragment = toPrismaFragment(kaneoSchema, { + exclude: DEFAULT_EXCLUDE, + rename: DEFAULT_RENAME, +}); +writeFileSync(path.join(import.meta.dir, "..", "kaneo.prisma"), fragment); +console.log(`wrote kaneo.prisma (${fragment.split("\n").length} lines)`); diff --git a/packages/kaneo-domain/src/drizzle.ts b/packages/kaneo-domain/src/drizzle.ts new file mode 100644 index 000000000..9b0f3488e --- /dev/null +++ b/packages/kaneo-domain/src/drizzle.ts @@ -0,0 +1,220 @@ +import { createId } from "@paralleldrive/cuid2"; +import { sql, Table } from "drizzle-orm"; +import { + boolean, + customType, + foreignKey, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + unique, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import type { + ColumnDef, + ColumnRef, + ForeignKeyDef, + IndexDef, + SchemaDef, + TableDef, +} from "./dsl"; + +const bytea = customType<{ data: Buffer; driverData: Buffer }>({ + dataType() { + return "bytea"; + }, +}); + +type PgTable = ReturnType; +type Column = ReturnType; + +const tableNameSymbol = ( + Table as unknown as { Symbol: { Name: symbol; Columns: symbol } } +).Symbol; + +export interface DrizzleSchema { + tables: Record; + columns: Record>; +} + +function buildColumn( + def: ColumnDef, + resolveRef: (ref: ColumnRef) => Column, +): any { + let col: any; + switch (def.type) { + case "text": + col = text(def.name); + break; + case "boolean": + col = boolean(def.name); + break; + case "integer": + col = integer(def.name); + break; + case "timestamp": + col = timestamp(def.name, { + mode: "date", + withTimezone: def.withTimezone, + }); + break; + case "jsonb": + col = jsonb(def.name); + break; + case "bytea": + col = bytea(def.name); + break; + } + + if (def.primary) { + col = col.primaryKey(); + } + if (def.notNull) { + col = col.notNull(); + } + if (def.unique !== null) { + col = def.unique === "" ? col.unique() : col.unique(def.unique); + } + const defaultValue = def.default; + if (defaultValue) { + switch (defaultValue.kind) { + case "literal": + col = col.default(defaultValue.value); + break; + case "now": + col = col.defaultNow(); + break; + case "cuid": + col = col.$defaultFn(() => createId()); + break; + case "client": + col = + defaultValue.value === false + ? col.$defaultFn(() => false) + : defaultValue.value === true + ? col.$defaultFn(() => true) + : col.$defaultFn(() => defaultValue.value); + break; + } + } + if (def.onUpdateNow) { + col = col.$onUpdate(() => new Date()); + } + const ref = def.ref; + if (ref) { + col = col.references(() => resolveRef(ref), { + onDelete: ref.onDelete, + onUpdate: ref.onUpdate, + }); + } + return col; +} + +function buildIndexes(def: TableDef, t: Record) { + const build = (idx: IndexDef) => { + const cols = idx.columns.map((c) => t[c]) as [any, ...any[]]; + switch (idx.kind) { + case "unique": + return unique(idx.name).on(...cols); + case "uniqueIndex": + return idx.where + ? uniqueIndex(idx.name) + .on(...cols) + .where(sql.raw(idx.where)) + : uniqueIndex(idx.name).on(...cols); + case "index": + return index(idx.name).on(...cols); + } + }; + return (def.indexes ?? []).map(build); +} + +function buildForeignKeys( + def: TableDef, + t: Record, + resolveTargetColumn: (table: string, column: string) => Column, +) { + const build = (fk: ForeignKeyDef) => { + const localColumns: any = fk.columns.map((c) => t[c]); + const foreignColumns: any = fk.refColumns.map((c) => + resolveTargetColumn(fk.refTable, c), + ); + const fkBuilder = foreignKey({ columns: localColumns, foreignColumns }); + if (fk.onDelete) { + fkBuilder.onDelete(fk.onDelete); + } + if (fk.onUpdate) { + fkBuilder.onUpdate(fk.onUpdate); + } + return fkBuilder; + }; + return (def.foreignKeys ?? []).map(build); +} + +export function toDrizzleSchema(schema: SchemaDef): DrizzleSchema { + const built: Record = {}; + const columns: Record> = {}; + + const resolveRef = (ref: ColumnRef): Column => { + const tableColumns = columns[ref.table]; + if (!tableColumns) { + throw new Error( + `kaneo domain: ref target table ${ref.table} is not built`, + ); + } + const refColumn = ref.columns[0]; + if (!refColumn) { + throw new Error( + `kaneo domain: ref target column ${ref.table} has no columns`, + ); + } + const col = tableColumns[refColumn]; + if (!col) { + throw new Error( + `kaneo domain: ref target column ${ref.table}.${refColumn} is not built`, + ); + } + return col; + }; + + const attachedColumns = (table: unknown) => + (table as Record)[tableNameSymbol.Columns] as + | Record + | undefined; + + for (const def of schema.tables) { + const columnBuilders: Record = {}; + for (const column of def.columns) { + columnBuilders[column.key] = buildColumn(column, resolveRef); + } + const table = pgTable(def.name, columnBuilders, (t) => [ + ...buildIndexes(def, t), + ...buildForeignKeys(def, t, (tableName, columnName) => { + const targetColumns = columns[tableName]; + const column = targetColumns?.[columnName]; + if (!column) { + throw new Error( + `kaneo domain: fk target column ${tableName}.${columnName} is not built`, + ); + } + return column; + }), + ]); + built[def.name] = table; + const tableColumnsMap: Record = {}; + columns[def.name] = tableColumnsMap; + for (const column of Object.values(attachedColumns(table) ?? {})) { + const loose = column as unknown as { + config?: { name?: string }; + name?: string; + }; + const physical = loose.config?.name ?? loose.name ?? ""; + tableColumnsMap[physical] = column; + } + } + + return { tables: built, columns }; +} diff --git a/packages/kaneo-domain/src/dsl.ts b/packages/kaneo-domain/src/dsl.ts new file mode 100644 index 000000000..d0ab9c186 --- /dev/null +++ b/packages/kaneo-domain/src/dsl.ts @@ -0,0 +1,207 @@ +export type ColumnType = + | "text" + | "boolean" + | "integer" + | "timestamp" + | "jsonb" + | "bytea"; + +export type RefAction = "cascade" | "set null" | "restrict"; + +export interface ColumnRef { + table: string; + columns: string[]; + onDelete?: RefAction; + onUpdate?: RefAction; +} + +export type ColumnDefault = + | { kind: "literal"; value: string | number | boolean } + | { kind: "now" } + | { kind: "cuid" } + | { kind: "client"; value: string | number | boolean }; + +export interface ColumnDef { + key: string; + name: string; + type: ColumnType; + withTimezone: boolean; + notNull: boolean; + primary: boolean; + unique: string | null; + default: ColumnDefault | null; + onUpdateNow: boolean; + ref: ColumnRef | null; +} + +export interface IndexDef { + name: string; + columns: string[]; + kind: "index" | "unique" | "uniqueIndex"; + where?: string; +} + +export interface ForeignKeyDef { + name?: string; + columns: string[]; + refTable: string; + refColumns: string[]; + onDelete?: RefAction; + onUpdate?: RefAction; +} + +export interface TableDef { + name: string; + columns: ColumnDef[]; + indexes?: IndexDef[]; + foreignKeys?: ForeignKeyDef[]; +} + +export class ColumnBuilder { + private readonly def: ColumnDef; + + constructor(key: string, name: string, type: ColumnType) { + this.def = { + key, + name, + type, + withTimezone: false, + notNull: false, + primary: false, + unique: null, + default: null, + onUpdateNow: false, + ref: null, + }; + } + + pk(): this { + this.def.primary = true; + return this; + } + + notNull(): this { + this.def.notNull = true; + return this; + } + + unique(name?: string): this { + this.def.unique = name ?? ""; + return this; + } + + default(value: string | number | boolean): this { + this.def.default = { kind: "literal", value }; + return this; + } + + defaultNow(): this { + this.def.default = { kind: "now" }; + return this; + } + + defaultCuid(): this { + this.def.default = { kind: "cuid" }; + return this; + } + + clientDefault(value: string | number | boolean): this { + this.def.default = { kind: "client", value }; + return this; + } + + onUpdateNow(): this { + this.def.onUpdateNow = true; + return this; + } + + withTimezone(): this { + this.def.withTimezone = true; + return this; + } + + ref( + table: string, + opts?: { columns?: string[]; onDelete?: RefAction; onUpdate?: RefAction }, + ): this { + this.def.ref = { + table, + columns: opts?.columns ?? ["id"], + onDelete: opts?.onDelete, + onUpdate: opts?.onUpdate, + }; + return this; + } + + toDef(): ColumnDef { + return this.def; + } +} + +export const t = { + text: (key: string, name: string) => new ColumnBuilder(key, name, "text"), + boolean: (key: string, name: string) => + new ColumnBuilder(key, name, "boolean"), + integer: (key: string, name: string) => + new ColumnBuilder(key, name, "integer"), + timestamp: (key: string, name: string) => + new ColumnBuilder(key, name, "timestamp"), + jsonb: (key: string, name: string) => new ColumnBuilder(key, name, "jsonb"), + bytea: (key: string, name: string) => new ColumnBuilder(key, name, "bytea"), +}; + +export function table( + name: string, + columns: Record, + opts?: { indexes?: IndexDef[]; foreignKeys?: ForeignKeyDef[] }, +): TableDef { + return { + name, + columns: Object.entries(columns).map(([key, builder]) => builder.toDef()), + indexes: opts?.indexes, + foreignKeys: opts?.foreignKeys, + }; +} + +export function index(name: string, columns: string[]): IndexDef { + return { name, columns, kind: "index" }; +} + +export function unique(name: string, columns: string[]): IndexDef { + return { name, columns, kind: "unique" }; +} + +export function uniqueIndex( + name: string, + columns: string[], + where?: string, +): IndexDef { + return { name, columns, kind: "uniqueIndex", where }; +} + +export function foreignKey(def: { + name?: string; + columns: string[]; + refTable: string; + refColumns: string[]; + onDelete?: RefAction; + onUpdate?: RefAction; +}): ForeignKeyDef { + return def; +} + +export interface SchemaDef { + tables: TableDef[]; +} + +export function defineSchema(tables: TableDef[]): SchemaDef { + return { tables }; +} + +export function schemaTable(schema: SchemaDef, name: string): TableDef { + const found = schema.tables.find((t) => t.name === name); + if (!found) { + throw new Error(`kaneo domain: no table named ${name}`); + } + return found; +} diff --git a/packages/kaneo-domain/src/index.ts b/packages/kaneo-domain/src/index.ts new file mode 100644 index 000000000..2d957632d --- /dev/null +++ b/packages/kaneo-domain/src/index.ts @@ -0,0 +1,4 @@ +export { toDrizzleSchema } from "./drizzle"; +export * from "./dsl"; +export { kaneoSchema } from "./kaneo"; +export { DEFAULT_EXCLUDE, DEFAULT_RENAME, toPrismaFragment } from "./prisma"; diff --git a/packages/kaneo-domain/src/kaneo.ts b/packages/kaneo-domain/src/kaneo.ts new file mode 100644 index 000000000..dafb363f8 --- /dev/null +++ b/packages/kaneo-domain/src/kaneo.ts @@ -0,0 +1,1043 @@ +import { + defineSchema, + foreignKey, + index, + t, + table, + unique, + uniqueIndex, +} from "./dsl"; + +export const kaneoSchema = defineSchema([ + table("user", { + id: t.text("id", "id").pk().defaultCuid(), + name: t.text("name", "name").notNull(), + email: t.text("email", "email").notNull().unique(), + emailVerified: t + .boolean("emailVerified", "emailVerified") + .notNull() + .clientDefault(false), + image: t.text("image", "image"), + locale: t.text("locale", "locale"), + createdAt: t.timestamp("createdAt", "createdAt").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updatedAt") + .defaultNow() + .onUpdateNow() + .notNull(), + isAnonymous: t.boolean("isAnonymous", "isAnonymous").default(false), + role: t.text("role", "role"), + banned: t.boolean("banned", "banned").default(false), + banReason: t.text("banReason", "banReason"), + banExpires: t.timestamp("banExpires", "banExpires"), + }), + table( + "session", + { + id: t.text("id", "id").pk(), + expiresAt: t.timestamp("expiresAt", "expiresAt").notNull(), + token: t.text("token", "token").notNull().unique(), + createdAt: t.timestamp("createdAt", "createdAt").defaultNow().notNull(), + updatedAt: t.timestamp("updatedAt", "updatedAt").onUpdateNow().notNull(), + ipAddress: t.text("ipAddress", "ipAddress"), + userAgent: t.text("userAgent", "userAgent"), + userId: t + .text("userId", "userId") + .notNull() + .ref("user", { onDelete: "cascade" }), + activeOrganizationId: t.text( + "activeOrganizationId", + "activeOrganizationId", + ), + activeTeamId: t.text("activeTeamId", "activeTeamId"), + impersonatedBy: t.text("impersonatedBy", "impersonatedBy"), + }, + { + indexes: [index("session_userId_idx", ["userId"])], + }, + ), + table( + "account", + { + id: t.text("id", "id").pk().defaultCuid(), + accountId: t.text("accountId", "accountId").notNull(), + providerId: t.text("providerId", "providerId").notNull(), + userId: t + .text("userId", "userId") + .notNull() + .ref("user", { onDelete: "cascade" }), + accessToken: t.text("accessToken", "accessToken"), + refreshToken: t.text("refreshToken", "refreshToken"), + idToken: t.text("idToken", "idToken"), + accessTokenExpiresAt: t.timestamp( + "accessTokenExpiresAt", + "accessToken_expires_at", + ), + refreshTokenExpiresAt: t.timestamp( + "refreshTokenExpiresAt", + "refreshToken_expires_at", + ), + scope: t.text("scope", "scope"), + password: t.text("password", "password"), + createdAt: t.timestamp("createdAt", "createdAt").defaultNow().notNull(), + updatedAt: t.timestamp("updatedAt", "updatedAt").onUpdateNow().notNull(), + }, + { + indexes: [index("account_userId_idx", ["userId"])], + }, + ), + table( + "user_avatar", + { + id: t.text("id", "id").pk().defaultCuid(), + userId: t + .text("userId", "user_id") + .notNull() + .unique("user_avatar_user_id_unique") + .ref("user", { onDelete: "cascade", onUpdate: "cascade" }), + mimeType: t.text("mimeType", "mime_type").notNull(), + size: t.integer("size", "size").notNull(), + data: t.bytea("data", "data").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [index("user_avatar_userId_idx", ["userId"])], + }, + ), + table( + "verification", + { + id: t.text("id", "id").pk().defaultCuid(), + identifier: t.text("identifier", "identifier").notNull(), + value: t.text("value", "value").notNull(), + expiresAt: t.timestamp("expiresAt", "expiresAt").notNull(), + createdAt: t.timestamp("createdAt", "createdAt").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updatedAt") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [index("verification_identifier_idx", ["identifier"])], + }, + ), + table("workspace", { + id: t.text("id", "id").pk().defaultCuid(), + name: t.text("name", "name").notNull(), + slug: t.text("slug", "slug").notNull().unique(), + logo: t.text("logo", "logo"), + metadata: t.text("metadata", "metadata"), + description: t.text("description", "description"), + createdAt: t.timestamp("createdAt", "created_at").notNull(), + }), + table( + "workspace_member", + { + id: t.text("id", "id").pk().defaultCuid(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade" }), + userId: t + .text("userId", "user_id") + .notNull() + .ref("user", { onDelete: "cascade" }), + role: t.text("role", "role").notNull().default("member"), + joinedAt: t.timestamp("joinedAt", "joined_at").notNull(), + }, + { + indexes: [ + index("workspace_member_workspaceId_idx", ["workspaceId"]), + index("workspace_member_userId_idx", ["userId"]), + ], + }, + ), + table( + "workspace_billing", + { + id: t.text("id", "id").pk().defaultCuid(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .unique("workspace_billing_workspace_id_unique") + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + foundingFree: t + .boolean("foundingFree", "founding_free") + .notNull() + .default(false), + trialEndsAt: t.timestamp("trialEndsAt", "trial_ends_at"), + creemCustomerId: t.text("creemCustomerId", "creem_customer_id"), + creemSubscriptionId: t + .text("creemSubscriptionId", "creem_subscription_id") + .unique(), + creemProductId: t.text("creemProductId", "creem_product_id"), + plan: t.text("plan", "plan"), + billingInterval: t.text("billingInterval", "billing_interval"), + status: t.text("status", "status"), + seats: t.integer("seats", "seats").notNull().default(1), + currentPeriodEnd: t.timestamp("currentPeriodEnd", "current_period_end"), + canceledAt: t.timestamp("canceledAt", "canceled_at"), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [index("workspace_billing_workspaceId_idx", ["workspaceId"])], + }, + ), + table("trial_grant", { + emailHash: t.text("emailHash", "email_hash").pk(), + trialEndsAt: t.timestamp("trialEndsAt", "trial_ends_at").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + }), + table("billing_event", { + id: t.text("id", "id").pk(), + eventType: t.text("eventType", "event_type").notNull(), + processedAt: t + .timestamp("processedAt", "processed_at") + .defaultNow() + .notNull(), + }), + table( + "team", + { + id: t.text("id", "id").pk(), + name: t.text("name", "name").notNull(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade" }), + createdAt: t.timestamp("createdAt", "created_at").notNull(), + updatedAt: t.timestamp("updatedAt", "updated_at").onUpdateNow(), + }, + { + indexes: [index("team_workspaceId_idx", ["workspaceId"])], + }, + ), + table( + "team_member", + { + id: t.text("id", "id").pk(), + teamId: t + .text("teamId", "team_id") + .notNull() + .ref("team", { onDelete: "cascade" }), + userId: t + .text("userId", "user_id") + .notNull() + .ref("user", { onDelete: "cascade" }), + createdAt: t.timestamp("createdAt", "created_at"), + }, + { + indexes: [ + index("teamMember_teamId_idx", ["teamId"]), + index("teamMember_userId_idx", ["userId"]), + ], + }, + ), + table( + "workspace_invitation", + { + id: t.text("id", "id").pk().defaultCuid(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade" }), + email: t.text("email", "email").notNull(), + role: t.text("role", "role"), + teamId: t.text("teamId", "team_id"), + status: t.text("status", "status").notNull().default("pending"), + expiresAt: t.timestamp("expiresAt", "expires_at").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + inviterId: t + .text("inviterId", "inviter_id") + .notNull() + .ref("user", { onDelete: "cascade" }), + }, + { + indexes: [ + index("workspace_invitation_workspaceId_idx", ["workspaceId"]), + index("workspace_invitation_email_idx", ["email"]), + index("workspace_invitation_inviterId_idx", ["inviterId"]), + ], + }, + ), + table( + "workspace_role", + { + id: t.text("id", "id").pk().defaultCuid(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + role: t.text("role", "role").notNull(), + permission: t.text("permission", "permission").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("workspace_role_workspaceId_idx", ["workspaceId"]), + index("workspace_role_role_idx", ["role"]), + ], + }, + ), + table( + "project", + { + id: t.text("id", "id").pk().defaultCuid(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + slug: t.text("slug", "slug").notNull(), + icon: t.text("icon", "icon").default("Layout"), + name: t.text("name", "name").notNull(), + description: t.text("description", "description"), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + isPublic: t.boolean("isPublic", "is_public").default(false), + archivedAt: t.timestamp("archivedAt", "archived_at"), + lastTaskNumber: t + .integer("lastTaskNumber", "last_task_number") + .notNull() + .default(0), + position: t.integer("position", "position").notNull().default(0), + }, + { + indexes: [ + unique("project_workspace_id_id_unique", ["workspaceId", "id"]), + index("project_workspaceId_position_idx", ["workspaceId", "position"]), + ], + }, + ), + table( + "column", + { + id: t.text("id", "id").pk().defaultCuid(), + projectId: t + .text("projectId", "project_id") + .notNull() + .ref("project", { onDelete: "cascade", onUpdate: "cascade" }), + name: t.text("name", "name").notNull(), + slug: t.text("slug", "slug").notNull(), + position: t.integer("position", "position").notNull().default(0), + icon: t.text("icon", "icon"), + color: t.text("color", "color"), + isFinal: t.boolean("isFinal", "is_final").notNull().default(false), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [index("column_projectId_idx", ["projectId"])], + }, + ), + table( + "workflow_rule", + { + id: t.text("id", "id").pk().defaultCuid(), + projectId: t + .text("projectId", "project_id") + .notNull() + .ref("project", { onDelete: "cascade", onUpdate: "cascade" }), + integrationType: t.text("integrationType", "integration_type").notNull(), + eventType: t.text("eventType", "event_type").notNull(), + columnId: t + .text("columnId", "column_id") + .notNull() + .ref("column", { onDelete: "cascade", onUpdate: "cascade" }), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("workflow_rule_projectId_idx", ["projectId"]), + index("workflow_rule_columnId_idx", ["columnId"]), + ], + }, + ), + table( + "task", + { + id: t.text("id", "id").pk().defaultCuid(), + projectId: t + .text("projectId", "project_id") + .notNull() + .ref("project", { onDelete: "cascade", onUpdate: "cascade" }), + position: t.integer("position", "position").default(0), + number: t.integer("number", "number").default(1), + userId: t + .text("userId", "assignee_id") + .ref("user", { onDelete: "set null", onUpdate: "cascade" }), + title: t.text("title", "title").notNull(), + description: t.text("description", "description"), + status: t.text("status", "status").notNull().default("to-do"), + columnId: t + .text("columnId", "column_id") + .ref("column", { onDelete: "set null", onUpdate: "cascade" }), + priority: t.text("priority", "priority").notNull().default("low"), + startDate: t.timestamp("startDate", "start_date"), + dueDate: t.timestamp("dueDate", "due_date"), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("task_projectId_idx", ["projectId"]), + index("task_dueDate_idx", ["dueDate"]), + index("task_assigneeId_idx", ["userId"]), + index("task_columnId_idx", ["columnId"]), + unique("task_project_number_unique", ["projectId", "number"]), + ], + }, + ), + table( + "billing_reminder_sent", + { + id: t.text("id", "id").pk().defaultCuid(), + userId: t + .text("userId", "user_id") + .notNull() + .ref("user", { onDelete: "cascade", onUpdate: "cascade" }), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + reminderType: t.text("reminderType", "reminder_type").notNull(), + trialEndsAt: t.timestamp("trialEndsAt", "trial_ends_at"), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("billing_reminder_sent_workspaceId_idx", ["workspaceId"]), + index("billing_reminder_sent_userId_idx", ["userId"]), + unique("billing_reminder_sent_user_type_unique", [ + "userId", + "reminderType", + ]), + ], + }, + ), + table("job_lease", { + name: t.text("name", "name").pk(), + owner: t.text("owner", "owner").notNull(), + expiresAt: t.timestamp("expiresAt", "expires_at").notNull(), + }), + table( + "task_reminder_sent", + { + id: t.text("id", "id").pk().defaultCuid(), + taskId: t + .text("taskId", "task_id") + .notNull() + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + reminderType: t.text("reminderType", "reminder_type").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("task_reminder_sent_taskId_idx", ["taskId"]), + unique("task_reminder_sent_task_type_unique", [ + "taskId", + "reminderType", + ]), + ], + }, + ), + table( + "time_entry", + { + id: t.text("id", "id").pk().defaultCuid(), + taskId: t + .text("taskId", "task_id") + .notNull() + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + userId: t + .text("userId", "user_id") + .ref("user", { onDelete: "set null", onUpdate: "cascade" }), + description: t.text("description", "description"), + startTime: t.timestamp("startTime", "start_time").notNull(), + endTime: t.timestamp("endTime", "end_time"), + duration: t.integer("duration", "duration").default(0), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("time_entry_taskId_idx", ["taskId"]), + index("time_entry_userId_idx", ["userId"]), + ], + }, + ), + table( + "task_activity", + { + id: t.text("id", "id").pk().defaultCuid(), + taskId: t + .text("taskId", "task_id") + .notNull() + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + type: t.text("type", "type").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + userId: t + .text("userId", "user_id") + .ref("user", { onDelete: "set null", onUpdate: "cascade" }), + content: t.text("content", "content"), + eventData: t.jsonb("eventData", "event_data"), + externalUserName: t.text("externalUserName", "external_user_name"), + externalUserAvatar: t.text("externalUserAvatar", "external_user_avatar"), + externalSource: t.text("externalSource", "external_source"), + externalUrl: t.text("externalUrl", "external_url"), + }, + { + indexes: [ + index("activity_task_id_idx", ["taskId"]), + index("activity_userId_idx", ["userId"]), + unique("activity_task_external_source_external_url_unique", [ + "taskId", + "externalSource", + "externalUrl", + ]), + ], + }, + ), + table( + "asset", + { + id: t.text("id", "id").pk().defaultCuid(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + projectId: t + .text("projectId", "project_id") + .notNull() + .ref("project", { onDelete: "cascade", onUpdate: "cascade" }), + taskId: t + .text("taskId", "task_id") + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + activityId: t + .text("activityId", "activity_id") + .ref("task_activity", { onDelete: "cascade", onUpdate: "cascade" }), + objectKey: t.text("objectKey", "object_key").notNull().unique(), + filename: t.text("filename", "filename").notNull(), + mimeType: t.text("mimeType", "mime_type").notNull(), + size: t.integer("size", "size").notNull(), + kind: t.text("kind", "kind").notNull().default("image"), + surface: t.text("surface", "surface").notNull().default("description"), + createdBy: t + .text("createdBy", "created_by") + .ref("user", { onDelete: "set null", onUpdate: "cascade" }), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + }, + { + indexes: [ + index("asset_workspaceId_idx", ["workspaceId"]), + index("asset_projectId_idx", ["projectId"]), + index("asset_taskId_idx", ["taskId"]), + index("asset_activityId_idx", ["activityId"]), + index("asset_createdBy_idx", ["createdBy"]), + ], + }, + ), + table( + "label", + { + id: t.text("id", "id").pk().defaultCuid(), + name: t.text("name", "name").notNull(), + color: t.text("color", "color").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + taskId: t + .text("taskId", "task_id") + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + workspaceId: t + .text("workspaceId", "workspace_id") + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + }, + { + indexes: [ + index("label_task_id_idx", ["taskId"]), + index("label_workspace_id_idx", ["workspaceId"]), + unique("label_task_name_unique", ["taskId", "name"]), + uniqueIndex( + "label_workspace_name_unique", + ["workspaceId", "name"], + "task_id is null", + ), + ], + }, + ), + table( + "notification", + { + id: t.text("id", "id").pk().defaultCuid(), + userId: t + .text("userId", "user_id") + .notNull() + .ref("user", { onDelete: "cascade", onUpdate: "cascade" }), + title: t.text("title", "title"), + content: t.text("content", "content"), + type: t.text("type", "type").notNull().default("info"), + eventData: t.jsonb("eventData", "event_data"), + isRead: t.boolean("isRead", "is_read").default(false), + resourceId: t.text("resourceId", "resource_id"), + resourceType: t.text("resourceType", "resource_type"), + createdAt: t + .timestamp("createdAt", "created_at") + .withTimezone() + .defaultNow() + .notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .withTimezone() + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [index("notification_userId_idx", ["userId"])], + }, + ), + table("user_notification_preference", { + id: t.text("id", "id").pk().defaultCuid(), + userId: t + .text("userId", "user_id") + .notNull() + .unique() + .ref("user", { onDelete: "cascade", onUpdate: "cascade" }), + emailEnabled: t + .boolean("emailEnabled", "email_enabled") + .notNull() + .default(false), + ntfyEnabled: t + .boolean("ntfyEnabled", "ntfy_enabled") + .notNull() + .default(false), + ntfyServerUrl: t.text("ntfyServerUrl", "ntfy_server_url"), + ntfyTopic: t.text("ntfyTopic", "ntfy_topic"), + ntfyToken: t.text("ntfyToken", "ntfy_token"), + gotifyEnabled: t + .boolean("gotifyEnabled", "gotify_enabled") + .notNull() + .default(false), + gotifyServerUrl: t.text("gotifyServerUrl", "gotify_server_url"), + gotifyToken: t.text("gotifyToken", "gotify_token"), + webhookEnabled: t + .boolean("webhookEnabled", "webhook_enabled") + .notNull() + .default(false), + webhookUrl: t.text("webhookUrl", "webhook_url"), + webhookSecret: t.text("webhookSecret", "webhook_secret"), + taskAssignmentEnabled: t + .boolean("taskAssignmentEnabled", "task_assignment_enabled") + .notNull() + .default(true), + taskCommentEnabled: t + .boolean("taskCommentEnabled", "task_comment_enabled") + .notNull() + .default(true), + taskStatusChangeEnabled: t + .boolean("taskStatusChangeEnabled", "task_status_change_enabled") + .notNull() + .default(true), + dueDateReminderEnabled: t + .boolean("dueDateReminderEnabled", "due_date_reminder_enabled") + .notNull() + .default(true), + dueDateReminderLeadTimeMinutes: t + .integer( + "dueDateReminderLeadTimeMinutes", + "due_date_reminder_lead_time_minutes", + ) + .notNull() + .default(1440), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }), + table( + "user_notification_workspace_rule", + { + id: t.text("id", "id").pk().defaultCuid(), + userId: t + .text("userId", "user_id") + .notNull() + .ref("user", { onDelete: "cascade", onUpdate: "cascade" }), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + isActive: t.boolean("isActive", "is_active").notNull().default(true), + emailEnabled: t + .boolean("emailEnabled", "email_enabled") + .notNull() + .default(false), + ntfyEnabled: t + .boolean("ntfyEnabled", "ntfy_enabled") + .notNull() + .default(false), + gotifyEnabled: t + .boolean("gotifyEnabled", "gotify_enabled") + .notNull() + .default(false), + webhookEnabled: t + .boolean("webhookEnabled", "webhook_enabled") + .notNull() + .default(false), + projectMode: t + .text("projectMode", "project_mode") + .notNull() + .default("all"), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("user_notification_workspace_rule_userId_idx", ["userId"]), + index("user_notification_workspace_rule_workspaceId_idx", [ + "workspaceId", + ]), + unique("user_notification_workspace_rule_user_workspace_unique", [ + "userId", + "workspaceId", + ]), + unique("user_notification_workspace_rule_workspace_id_id_unique", [ + "workspaceId", + "id", + ]), + ], + }, + ), + table( + "user_notification_workspace_project", + { + id: t.text("id", "id").pk().defaultCuid(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + workspaceRuleId: t.text("workspaceRuleId", "workspace_rule_id").notNull(), + projectId: t.text("projectId", "project_id").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("user_notification_workspace_project_ruleId_idx", [ + "workspaceRuleId", + ]), + index("user_notification_workspace_project_projectId_idx", [ + "projectId", + ]), + index("user_notification_workspace_project_workspaceId_projectId_idx", [ + "workspaceId", + "projectId", + ]), + index("unwp_workspaceId_workspaceRuleId_idx", [ + "workspaceId", + "workspaceRuleId", + ]), + unique("user_notification_workspace_project_rule_project_unique", [ + "workspaceRuleId", + "projectId", + ]), + ], + foreignKeys: [ + foreignKey({ + columns: ["workspaceId", "workspaceRuleId"], + refTable: "user_notification_workspace_rule", + refColumns: ["workspace_id", "id"], + onDelete: "cascade", + onUpdate: "cascade", + }), + foreignKey({ + columns: ["workspaceId", "projectId"], + refTable: "project", + refColumns: ["workspace_id", "id"], + onDelete: "cascade", + onUpdate: "cascade", + }), + ], + }, + ), + table("github_integration", { + id: t.text("id", "id").pk().defaultCuid(), + projectId: t + .text("projectId", "project_id") + .notNull() + .unique() + .ref("project", { onDelete: "cascade", onUpdate: "cascade" }), + repositoryOwner: t.text("repositoryOwner", "repository_owner").notNull(), + repositoryName: t.text("repositoryName", "repository_name").notNull(), + installationId: t.integer("installationId", "installation_id"), + isActive: t.boolean("isActive", "is_active").default(true), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }), + table( + "integration", + { + id: t.text("id", "id").pk().defaultCuid(), + projectId: t + .text("projectId", "project_id") + .notNull() + .ref("project", { onDelete: "cascade", onUpdate: "cascade" }), + type: t.text("type", "type").notNull(), + config: t.text("config", "config").notNull(), + isActive: t.boolean("isActive", "is_active").default(true), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("integration_projectId_idx", ["projectId"]), + index("integration_type_idx", ["type"]), + unique("integration_project_type_unique", ["projectId", "type"]), + ], + }, + ), + table( + "external_link", + { + id: t.text("id", "id").pk().defaultCuid(), + taskId: t + .text("taskId", "task_id") + .notNull() + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + integrationId: t + .text("integrationId", "integration_id") + .notNull() + .ref("integration", { onDelete: "cascade", onUpdate: "cascade" }), + resourceType: t.text("resourceType", "resource_type").notNull(), + externalId: t.text("externalId", "external_id").notNull(), + url: t.text("url", "url").notNull(), + title: t.text("title", "title"), + metadata: t.text("metadata", "metadata"), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("external_link_taskId_idx", ["taskId"]), + index("external_link_integrationId_idx", ["integrationId"]), + index("external_link_externalId_idx", ["externalId"]), + index("external_link_resourceType_idx", ["resourceType"]), + ], + }, + ), + table( + "comment", + { + id: t.text("id", "id").pk().defaultCuid(), + taskId: t + .text("taskId", "task_id") + .notNull() + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + userId: t + .text("userId", "user_id") + .notNull() + .ref("user", { onDelete: "cascade", onUpdate: "cascade" }), + content: t.text("content", "content").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("comment_task_idx", ["taskId"]), + index("comment_user_idx", ["userId"]), + ], + }, + ), + table( + "task_relation", + { + id: t.text("id", "id").pk().defaultCuid(), + sourceTaskId: t + .text("sourceTaskId", "source_task_id") + .notNull() + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + targetTaskId: t + .text("targetTaskId", "target_task_id") + .notNull() + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + relationType: t.text("relationType", "relation_type").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + }, + { + indexes: [ + index("task_relation_source_idx", ["sourceTaskId"]), + index("task_relation_target_idx", ["targetTaskId"]), + ], + }, + ), + table( + "apikey", + { + id: t.text("id", "id").pk().defaultCuid(), + configId: t.text("configId", "configId").notNull().default("default"), + name: t.text("name", "name"), + start: t.text("start", "start"), + referenceId: t + .text("referenceId", "referenceId") + .notNull() + .ref("user", { onDelete: "cascade" }), + prefix: t.text("prefix", "prefix"), + key: t.text("key", "key").notNull(), + userId: t.text("userId", "userId").ref("user", { onDelete: "cascade" }), + refillInterval: t.integer("refillInterval", "refillInterval"), + refillAmount: t.integer("refillAmount", "refillAmount"), + lastRefillAt: t.timestamp("lastRefillAt", "lastRefillAt"), + enabled: t.boolean("enabled", "enabled").default(true), + rateLimitEnabled: t + .boolean("rateLimitEnabled", "rateLimitEnabled") + .default(true), + rateLimitTimeWindow: t + .integer("rateLimitTimeWindow", "rateLimitTimeWindow") + .default(86400000), + rateLimitMax: t.integer("rateLimitMax", "rateLimitMax").default(10), + requestCount: t.integer("requestCount", "requestCount").default(0), + remaining: t.integer("remaining", "remaining"), + lastRequest: t.timestamp("lastRequest", "lastRequest"), + expiresAt: t.timestamp("expiresAt", "expiresAt"), + createdAt: t.timestamp("createdAt", "createdAt").notNull(), + updatedAt: t.timestamp("updatedAt", "updatedAt").notNull(), + permissions: t.text("permissions", "permissions"), + metadata: t.text("metadata", "metadata"), + }, + { + indexes: [ + index("apikey_configId_idx", ["configId"]), + index("apikey_key_idx", ["key"]), + index("apikey_referenceId_idx", ["referenceId"]), + index("apikey_userId_idx", ["userId"]), + ], + }, + ), + table( + "device_code", + { + id: t.text("id", "id").pk().defaultCuid(), + deviceCode: t.text("deviceCode", "device_code").notNull(), + userCode: t.text("userCode", "user_code").notNull(), + userId: t + .text("userId", "user_id") + .ref("user", { onDelete: "cascade", onUpdate: "cascade" }), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + expiresAt: t.timestamp("expiresAt", "expires_at").notNull(), + status: t.text("status", "status").notNull(), + lastPolledAt: t.timestamp("lastPolledAt", "last_polled_at"), + pollingInterval: t.integer("pollingInterval", "polling_interval"), + clientId: t.text("clientId", "client_id"), + scope: t.text("scope", "scope"), + }, + { + indexes: [ + uniqueIndex("device_code_device_code_uidx", ["deviceCode"]), + uniqueIndex("device_code_user_code_uidx", ["userCode"]), + index("device_code_user_id_idx", ["userId"]), + ], + }, + ), + table( + "mcp_oauth_state", + { + id: t.text("id", "id").pk().defaultCuid(), + kind: t.text("kind", "kind").notNull(), + key: t.text("key", "key").notNull(), + payload: t.jsonb("payload", "payload").notNull(), + expiresAt: t.timestamp("expiresAt", "expires_at").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + uniqueIndex("mcp_oauth_state_kind_key_uidx", ["kind", "key"]), + index("mcp_oauth_state_expiresAt_idx", ["expiresAt"]), + ], + }, + ), +]); diff --git a/packages/kaneo-domain/src/parity.test.ts b/packages/kaneo-domain/src/parity.test.ts new file mode 100644 index 000000000..bc87f1325 --- /dev/null +++ b/packages/kaneo-domain/src/parity.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "bun:test"; +import * as vendored from "../../../vendor/kaneo/apps/api/src/database/schema.ts"; +import { toDrizzleSchema } from "./drizzle"; +import { kaneoSchema } from "./kaneo"; +import { renderTable } from "./parity"; + +const PHYSICAL_TO_VENDORED = { + user: "userTable", + session: "sessionTable", + account: "accountTable", + user_avatar: "userAvatarTable", + verification: "verificationTable", + workspace: "workspaceTable", + workspace_member: "workspaceUserTable", + workspace_billing: "workspaceBillingTable", + trial_grant: "trialGrantTable", + billing_event: "billingEventTable", + team: "teamTable", + team_member: "teamMemberTable", + workspace_invitation: "invitationTable", + workspace_role: "workspaceRoleTable", + project: "projectTable", + column: "columnTable", + workflow_rule: "workflowRuleTable", + task: "taskTable", + billing_reminder_sent: "billingReminderSentTable", + job_lease: "jobLeaseTable", + task_reminder_sent: "taskReminderSentTable", + time_entry: "timeEntryTable", + task_activity: "activityTable", + asset: "assetTable", + label: "labelTable", + notification: "notificationTable", + user_notification_preference: "userNotificationPreferenceTable", + user_notification_workspace_rule: "userNotificationWorkspaceRuleTable", + user_notification_workspace_project: "userNotificationWorkspaceProjectTable", + github_integration: "githubIntegrationTable", + integration: "integrationTable", + external_link: "externalLinkTable", + comment: "commentTable", + task_relation: "taskRelationTable", + apikey: "apikeyTable", + device_code: "deviceCodeTable", + mcp_oauth_state: "mcpOauthStateTable", +}; + +describe("kaneo drizzle parity", () => { + const generated = toDrizzleSchema(kaneoSchema); + + it("exposes every vendored table", () => { + for (const [physical, exportName] of Object.entries(PHYSICAL_TO_VENDORED)) { + expect( + vendored[exportName as keyof typeof vendored], + `${exportName} exists`, + ).toBeDefined(); + expect(generated.tables[physical], `generated ${physical}`).toBeDefined(); + } + }); + + for (const [physical, exportName] of Object.entries(PHYSICAL_TO_VENDORED)) { + it(`${physical} (${exportName}) matches the vendored schema`, () => { + const original = vendored[exportName as keyof typeof vendored]; + const generatedTable = generated.tables[physical]; + expect(renderTable(generatedTable)).toBe(renderTable(original)); + }); + } +}); diff --git a/packages/kaneo-domain/src/parity.ts b/packages/kaneo-domain/src/parity.ts new file mode 100644 index 000000000..82d853eb8 --- /dev/null +++ b/packages/kaneo-domain/src/parity.ts @@ -0,0 +1,152 @@ +import { Table } from "drizzle-orm"; +import { getTableConfig } from "drizzle-orm/pg-core"; + +const tableNameSymbol = (Table as unknown as { Symbol: { Name: symbol } }) + .Symbol.Name; + +function sqlText(chunk: unknown, out: string[]): void { + if (Array.isArray(chunk)) { + if (typeof chunk[0] === "string") { + out.push(chunk[0]); + } else { + for (const inner of chunk) { + sqlText(inner, out); + } + } + return; + } + if (chunk !== null && typeof chunk === "object") { + const obj = chunk as Record; + if (Array.isArray(obj.queryChunks)) { + for (const inner of obj.queryChunks as unknown[]) { + sqlText(inner, out); + } + return; + } + if (Array.isArray(obj.value)) { + for (const inner of obj.value as unknown[]) { + sqlText(inner, out); + } + return; + } + if ( + typeof obj.getSQL === "function" && + typeof obj.name === "string" && + obj.table + ) { + out.push(obj.name as string); + return; + } + if (typeof obj.isTable === "boolean" && obj.isTable) { + out.push(obj.name as string); + return; + } + out.push(String(obj.name ?? chunk)); + return; + } + out.push(String(chunk)); +} + +function normalizeSql(sql: unknown): string { + const out: string[] = []; + sqlText(sql, out); + return out.join("").replaceAll('"', "").replace(/\s+/g, " ").trim(); +} + +function tableName(table: unknown): string { + if (table === null || typeof table !== "object") { + return String(table); + } + const t = table as Record; + const name = t[tableNameSymbol]; + if (typeof name === "string") { + return name; + } + const config = t.config as { name?: string } | undefined; + if (config?.name) { + return config.name; + } + return String(t.name); +} + +function tokenizeDefault(column: { + defaultFn?: unknown; + default?: unknown; +}): string { + if (column.defaultFn) { + const src = String(column.defaultFn) + .replaceAll("!1", "false") + .replaceAll("!0", "true"); + if (src.includes("createId")) { + return "cuid"; + } + return `fn:${src.slice(0, 60)}`; + } + if (column.default === undefined || column.default === null) { + return "-"; + } + if (typeof column.default === "object") { + return `sql:${normalizeSql(column.default)}`; + } + return `lit:${String(column.default)}`; +} + +interface DrizzleColumn { + name: string; + notNull: boolean; + primary: boolean; + isUnique: boolean; + uniqueName?: string; + default?: unknown; + defaultFn?: unknown; + onUpdateFn?: unknown; + getSQLType(): string; +} + +export function renderTable(table: unknown): string { + const cfg = getTableConfig(table as Parameters[0]); + const lines: string[] = []; + lines.push(`TABLE ${cfg.name}`); + for (const col of cfg.columns as DrizzleColumn[]) { + const uniqueName = col.isUnique ? (col.uniqueName ?? "auto") : "-"; + lines.push( + `COL ${col.name} type=${col.getSQLType()} nn=${col.notNull} pk=${col.primary} uq=${uniqueName} def=${tokenizeDefault(col)} upd=${col.onUpdateFn ? 1 : 0}`, + ); + } + for (const idx of cfg.indexes ?? []) { + const c = ( + idx as unknown as { + config: { + name: string; + unique: boolean; + columns: { name: string }[]; + where?: unknown; + }; + } + ).config; + const where = c.where ? normalizeSql(c.where) : "-"; + lines.push( + `IDX ${c.name} unique=${c.unique} cols=[${c.columns.map((x) => x.name).join(",")}] where=${where}`, + ); + } + for (const uc of cfg.uniqueConstraints ?? []) { + const columns = (uc.columns as unknown[]).map( + (c) => (c as { name?: string }).name ?? String(c), + ); + lines.push(`UC ${uc.name} cols=[${columns.join(",")}]`); + } + for (const fk of cfg.foreignKeys ?? []) { + const ref = fk.reference() as { + name?: string; + columns: DrizzleColumn[]; + foreignTable: unknown; + foreignColumns: DrizzleColumn[]; + }; + const local = ref.columns.map((c) => c.name).join(","); + const foreign = ref.foreignColumns.map((c) => c.name).join(","); + lines.push( + `FK ${ref.name ?? "-"} local=[${local}] -> ${tableName(ref.foreignTable)}(${foreign}) del=${fk.onDelete ?? "-"} upd=${fk.onUpdate ?? "-"}`, + ); + } + return lines.join("\n"); +} diff --git a/packages/kaneo-domain/src/prisma.test.ts b/packages/kaneo-domain/src/prisma.test.ts new file mode 100644 index 000000000..30476c7c4 --- /dev/null +++ b/packages/kaneo-domain/src/prisma.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import { kaneoSchema } from "./kaneo"; +import { DEFAULT_EXCLUDE, DEFAULT_RENAME, toPrismaFragment } from "./prisma"; + +const fragment = toPrismaFragment(kaneoSchema, { + exclude: DEFAULT_EXCLUDE, + rename: DEFAULT_RENAME, +}); + +describe("kaneo prisma fragment", () => { + it("emits every non-excluded kaneo table", () => { + const kept = kaneoSchema.tables.filter( + (t) => !DEFAULT_EXCLUDE.includes(t.name), + ); + for (const def of kept) { + expect(fragment).toContain(`@@map("${def.name}")`); + } + expect(fragment.match(/^model /gm)).toHaveLength(kept.length); + }); + + it("skips the excluded tables", () => { + for (const table of DEFAULT_EXCLUDE) { + expect(fragment).not.toContain(`@@map("${table}")`); + } + }); + + it("renames the collisions", () => { + expect(fragment).toContain("model ProjectTask"); + expect(fragment).toContain("model ProjectColumn"); + expect(fragment).toContain("model TaskActivity"); + expect(fragment).toContain("model TaskComment"); + }); + + it("never combines a scalar and a relation on one line", () => { + for (const line of fragment.split("\n")) { + expect(line).not.toMatch( + /^ {2}\w+ (String|Int|Boolean|DateTime|Json|Bytes)\?* \w+ [A-Z]\w* @relation/, + ); + } + }); + + it("maps columns to kaneo's snake_case physical names", () => { + expect(fragment).toContain('@map("project_id")'); + expect(fragment).toContain('@map("created_at")'); + expect(fragment).toContain('@map("joined_at")'); + }); +}); diff --git a/packages/kaneo-domain/src/prisma.ts b/packages/kaneo-domain/src/prisma.ts new file mode 100644 index 000000000..c9dd36154 --- /dev/null +++ b/packages/kaneo-domain/src/prisma.ts @@ -0,0 +1,299 @@ +import type { + ColumnDef, + ColumnRef, + ForeignKeyDef, + IndexDef, + RefAction, + SchemaDef, + TableDef, +} from "./dsl"; + +export interface PrismaBindingOptions { + exclude: string[]; + rename: Record; +} + +export const DEFAULT_EXCLUDE = [ + "user", + "session", + "account", + "verification", + "apikey", +]; + +export const DEFAULT_RENAME = { + task: "ProjectTask", + column: "ProjectColumn", + comment: "TaskComment", + activity: "TaskActivity", + workspace_invitation: "WorkspaceInvitation", +} as const satisfies Record; + +const REF_ACTIONS = { + cascade: "Cascade", + "set null": "SetNull", + restrict: "Restrict", +} as const satisfies Record; + +interface Edge { + localColumns: string[]; + refColumns: string[]; + target: string; + onDelete?: RefAction; + onUpdate?: RefAction; +} + +interface PrismaRelation { + model: string; + field: string; + target: string; + localKeys: string[]; + refKeys: string[]; + onDelete?: RefAction; + onUpdate?: RefAction; + named?: string; + backField: string; +} + +function pascalCase(name: string): string { + return name + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); +} + +function camelCase(name: string): string { + return name.charAt(0).toLowerCase() + name.slice(1); +} + +function pluralize(name: string): string { + if (/[^aeiou]y$/i.test(name)) { + return `${name.slice(0, -1)}ies`; + } + return `${name}s`; +} + +function prismaType(column: ColumnDef): string { + switch (column.type) { + case "text": + return "String"; + case "boolean": + return "Boolean"; + case "integer": + return "Int"; + case "timestamp": + return "DateTime"; + case "jsonb": + return "Json"; + case "bytea": + return "Bytes"; + } +} + +function defaultAttribute(column: ColumnDef): string | null { + const value = column.default; + if (!value) { + return null; + } + switch (value.kind) { + case "cuid": + return "@default(cuid())"; + case "now": + return "@default(now())"; + case "literal": + if (typeof value.value === "string") { + return `@default("${value.value}")`; + } + return `@default(${value.value})`; + case "client": + return null; + } +} + +function scalarAttributes(column: ColumnDef): string { + const parts: string[] = []; + if (column.primary) { + parts.push("@id"); + } + if (column.unique !== null) { + parts.push( + column.unique === "" ? "@unique" : `@unique(map: "${column.unique}")`, + ); + } + const def = defaultAttribute(column); + if (def) { + parts.push(def); + } + if (column.onUpdateNow) { + parts.push("@updatedAt"); + } + return parts.join(" "); +} + +function relationName( + model: string, + target: string, + index: number, +): string | undefined { + if (index === 0) { + return undefined; + } + return `${model}To${target}_${index}`; +} + +function relationFieldName(localKeys: string[], target: string): string { + if (localKeys.length === 1) { + const key = localKeys[0]; + if (key) { + const stripped = key.replace(/Id$/, ""); + if (stripped) { + return stripped; + } + } + } + return camelCase(target); +} + +function keyMap(def: TableDef): Map { + return new Map(def.columns.map((c) => [c.name, c.key])); +} + +function collectEdges(def: TableDef): Edge[] { + const fromColumn = (column: ColumnDef, ref: ColumnRef): Edge => ({ + localColumns: [column.name], + refColumns: ref.columns, + target: ref.table, + onDelete: ref.onDelete, + onUpdate: ref.onUpdate, + }); + const fromFk = (fk: ForeignKeyDef): Edge => ({ + localColumns: fk.columns, + refColumns: fk.refColumns, + target: fk.refTable, + onDelete: fk.onDelete, + onUpdate: fk.onUpdate, + }); + const columnEdges = def.columns + .filter((c) => c.ref) + .map((c) => fromColumn(c, c.ref!)); + return [...columnEdges, ...(def.foreignKeys ?? []).map(fromFk)]; +} + +export function toPrismaFragment( + schema: SchemaDef, + options: PrismaBindingOptions, +): string { + const tables = schema.tables.filter((t) => !options.exclude.includes(t.name)); + const modelName = (physical: string) => + options.rename[physical] ?? pascalCase(physical); + const keyMaps = new Map(tables.map((t) => [t.name, keyMap(t)])); + + const relations: PrismaRelation[] = []; + for (const def of tables) { + const edges = collectEdges(def); + const seen = new Map(); + for (const edge of edges) { + const pairKey = `${def.name}>${edge.target}`; + const index = seen.get(pairKey) ?? 0; + seen.set(pairKey, index + 1); + const targetMap = keyMaps.get(edge.target); + if (!targetMap) { + continue; + } + const localMap = keyMaps.get(def.name)!; + const localKeys = edge.localColumns.map((c) => localMap.get(c) ?? c); + const refKeys = edge.refColumns.map((c) => targetMap.get(c) ?? c); + const named = relationName( + modelName(def.name), + modelName(edge.target), + index, + ); + relations.push({ + model: modelName(def.name), + field: relationFieldName(localKeys, modelName(edge.target)), + target: modelName(edge.target), + localKeys, + refKeys, + onDelete: edge.onDelete, + onUpdate: edge.onUpdate, + named, + backField: pluralize(camelCase(modelName(def.name))), + }); + } + } + + const blocks: string[] = []; + for (const def of tables) { + const name = modelName(def.name); + const modelRelations = relations.filter((r) => r.model === name); + const backRelations = relations.filter((r) => r.target === name); + const usedBackFields = new Set(); + + const lines: string[] = []; + for (const column of def.columns) { + const base = `${column.key} ${prismaType(column)}${column.notNull || column.primary ? "" : "?"}`; + const columnMap = + column.key === column.name ? "" : ` @map("${column.name}")`; + const attrs = scalarAttributes(column); + lines.push(`${base}${columnMap}${attrs ? ` ${attrs}` : ""}`); + } + + for (const relation of modelRelations) { + const optional = relation.localKeys.some((key) => { + const column = def.columns.find((c) => c.key === key); + return column ? !column.notNull && !column.primary : false; + }); + lines.push( + `${relation.field} ${relation.target}${optional ? "?" : ""} @relation(${relationArgs(relation)})`, + ); + } + + for (const relation of backRelations) { + let backField = relation.backField; + let suffix = 2; + while (usedBackFields.has(backField)) { + backField = `${relation.backField}${suffix}`; + suffix += 1; + } + usedBackFields.add(backField); + const relName = relation.named ? `"${relation.named}"` : ""; + lines.push( + `${backField} ${relation.model}[]${relName ? ` @relation(${relName})` : ""}`, + ); + } + + for (const idx of def.indexes ?? []) { + if (idx.kind === "unique") { + lines.push(`@@unique([${idx.columns.join(", ")}], map: "${idx.name}")`); + } + } + for (const idx of def.indexes ?? []) { + if (idx.kind === "index") { + lines.push(`@@index([${idx.columns.join(", ")}], map: "${idx.name}")`); + } + } + + blocks.push( + `model ${name} {\n${lines.map((l) => ` ${l}`).join("\n")}\n @@map("${def.name}")\n}`, + ); + } + + return blocks.join("\n\n"); + + function relationArgs(relation: PrismaRelation): string { + const parts = [ + `fields: [${relation.localKeys.join(", ")}]`, + `references: [${relation.refKeys.join(", ")}]`, + ]; + if (relation.named) { + parts.unshift(`"${relation.named}"`); + } + if (relation.onDelete) { + parts.push(`onDelete: ${REF_ACTIONS[relation.onDelete]}`); + } + if (relation.onUpdate) { + parts.push(`onUpdate: ${REF_ACTIONS[relation.onUpdate]}`); + } + return parts.join(", "); + } +} diff --git a/packages/kaneo-domain/tsconfig.json b/packages/kaneo-domain/tsconfig.json new file mode 100644 index 000000000..d427aa526 --- /dev/null +++ b/packages/kaneo-domain/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@crm/typescript-config/internal-package.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "src/**/*.test.ts"] +} diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 149906cf8..b1f60809f 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -18,11 +18,11 @@ "dependencies": { "@crm/db": "workspace:*", "@crm/env": "workspace:*", - "posthog-node": "^5.48.0" + "posthog-node": "^5.51.8" }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/node": "^24.10.1", - "typescript": "5.9.2" + "@types/node": "^26.5.0", + "typescript": "7.0.2" } } diff --git a/packages/telemetry/src/allowlist.ts b/packages/telemetry/src/allowlist.ts index 5880dc2bd..1b9ec9aec 100644 --- a/packages/telemetry/src/allowlist.ts +++ b/packages/telemetry/src/allowlist.ts @@ -48,7 +48,6 @@ export const ALLOWED_PROPERTIES = [ "facts_by_method", "facts_by_evidence_kind", "fact_dismissal_rate", - "fact_decision_median_hours", "facts_superseded_within_7_days", "contacts_bucket", @@ -126,6 +125,7 @@ export const AGENT_TOOLS = [ "list_fields", "list_outstanding_work", "manage_fields", + "project_list", "read_company_history", "read_crm_history", "read_deal_history", @@ -139,6 +139,11 @@ export const AGENT_TOOLS = [ "set_chat_title", "set_contact_socials", "set_field_value", + "task_comment", + "task_create", + "task_list", + "task_read", + "kaneo_task_update", "write_brief", "write_workspace_profile", ] as const; diff --git a/packages/ui/package.json b/packages/ui/package.json index 504419a28..5e63847a6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,45 +17,45 @@ "clean": "rm -rf .turbo node_modules" }, "dependencies": { - "@carbon/icons-react": "^11.82.0", + "@carbon/icons-react": "^11.88.0", "@crm/db": "workspace:*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@shadcn/react": "^0.3.0", + "@shadcn/react": "^0.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "lucide-react": "^1.28.0", - "motion": "^12.42.2", + "lucide-react": "^1.43.0", + "motion": "^13.2.0", "next-themes": "^0.4.6", - "nuqs": "^2.8.9", - "radix-ui": "^1.6.0", + "nuqs": "^2.10.1", + "radix-ui": "^1.6.7", "react-day-picker": "^10.0.1", - "recharts": "3.8.0", - "sonner": "^2.0.7", - "streamdown": "^2.5.0", + "recharts": "3.10.1", + "sonner": "^2.0.8", + "streamdown": "^2.6.0", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2" }, "peerDependencies": { - "next": "^16.0.0", - "react": "^19.2.0", - "react-dom": "^19.2.0" + "next": "^16.3.4", + "react": "^19.2.8", + "react-dom": "^19.2.8" }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@tailwindcss/postcss": "^4", - "@types/node": "^24.10.1", + "@tailwindcss/postcss": "^4.3.3", + "@types/node": "^26.5.0", "@types/react": "^19.2.18", "@types/react-dom": "^19", - "next": "16.2.12", - "react": "19.2.4", - "react-dom": "19.2.4", - "shadcn": "^4.16.1", - "tailwindcss": "^4", - "typescript": "5.9.2" + "next": "16.3.4", + "react": "19.2.8", + "react-dom": "19.2.8", + "shadcn": "^4.21.0", + "tailwindcss": "^4.3.3", + "typescript": "7.0.2" } } diff --git a/packages/validation/package.json b/packages/validation/package.json index d6fb2c36a..2921268a2 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -5,16 +5,19 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./active-organization-claim": "./src/active-organization-claim.ts", "./activity-meta": "./src/activity-meta.ts", "./agent-events": "./src/agent-events.ts", "./agent-manifest": "./src/agent-manifest.ts", + "./api-key-principal": "./src/api-key-principal.ts", "./builder-question": "./src/builder-question.ts", "./enrichment-queue": "./src/enrichment-queue.ts", "./eve-stream": "./src/eve-stream.ts", "./eve-tool": "./src/eve-tool.ts", "./field-backfill": "./src/field-backfill.ts", "./field-templates": "./src/field-templates.ts", - "./saved-view": "./src/saved-view.ts" + "./saved-view": "./src/saved-view.ts", + "./workspace-gate": "./src/workspace-gate.ts" }, "scripts": { "check-types": "tsc --noEmit", @@ -24,11 +27,11 @@ }, "dependencies": { "@crm/db": "workspace:*", - "zod": "^4.4.3" + "zod": "^4.5.4" }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/node": "^24.10.1", - "typescript": "5.9.2" + "@types/node": "^26.5.0", + "typescript": "7.0.2" } } diff --git a/packages/validation/src/active-organization-claim.ts b/packages/validation/src/active-organization-claim.ts new file mode 100644 index 000000000..7f3eca07a --- /dev/null +++ b/packages/validation/src/active-organization-claim.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +const activeOrganizationClaim = z.string().trim().min(1); + +export function parseActiveOrganizationClaim(value: unknown): string | null { + const parsed = activeOrganizationClaim.safeParse(value); + return parsed.success ? parsed.data : null; +} diff --git a/packages/validation/src/api-key-principal.ts b/packages/validation/src/api-key-principal.ts new file mode 100644 index 000000000..f8586da99 --- /dev/null +++ b/packages/validation/src/api-key-principal.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +const apiKeyPrincipalMetadata = z.object({ + createdByUserId: z.string().trim().min(1), +}); + +export type ApiKeyPrincipalMetadata = z.infer; + +export function parseApiKeyPrincipalMetadata( + value: unknown, +): ApiKeyPrincipalMetadata { + return apiKeyPrincipalMetadata.parse(value); +} diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 0cf43c9ca..50f40bdc9 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -7,6 +7,7 @@ import * as builderQuestion from "./builder-question"; import * as eveStream from "./eve-stream"; import * as eveTool from "./eve-tool"; import * as slack from "./slack"; +import * as workspaceGate from "./workspace-gate"; export const schemas = { activityMeta, @@ -17,6 +18,7 @@ export const schemas = { eveStream, eveTool, slack, + workspaceGate, } as const; export type { ActivityMeta, ActivityMetaFields } from "./activity-meta"; @@ -57,6 +59,7 @@ export type { EveToolOutput, } from "./eve-tool"; export type { AuthTest, JoinPayload, OauthAccess, Reply } from "./slack"; +export type { WorkspaceGate } from "./workspace-gate"; export class InvalidInput extends Error { override readonly name = "InvalidInput"; diff --git a/packages/validation/src/slack.ts b/packages/validation/src/slack.ts index 7d276a21b..df7ba2283 100644 --- a/packages/validation/src/slack.ts +++ b/packages/validation/src/slack.ts @@ -15,6 +15,7 @@ export const createPayload = z.object({ .max(80) .regex(/^[a-z0-9-_]+$/, "Use lowercase letters, numbers and dashes."), isPrivate: z.boolean(), + organizationId: z.string().trim().min(1).max(120), }); export const createReply = z.object({ diff --git a/packages/validation/src/workspace-gate.ts b/packages/validation/src/workspace-gate.ts new file mode 100644 index 000000000..602cfb2dd --- /dev/null +++ b/packages/validation/src/workspace-gate.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const workspaceGate = z.object({ + organizationId: z.string().min(1).nullable(), + slug: z.string().min(1).nullable(), + onboarded: z.boolean().nullable(), + canRename: z.boolean().nullable(), +}); + +export type WorkspaceGate = z.infer; diff --git a/packages/validation/test/api-key-principal.spec.ts b/packages/validation/test/api-key-principal.spec.ts new file mode 100644 index 000000000..091aa53b7 --- /dev/null +++ b/packages/validation/test/api-key-principal.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "bun:test"; +import { z } from "zod"; +import { parseApiKeyPrincipalMetadata } from "../src/api-key-principal"; + +describe("parseApiKeyPrincipalMetadata", () => { + it("returns the API key creator", () => { + expect(parseApiKeyPrincipalMetadata({ createdByUserId: "user-1" })).toEqual( + { createdByUserId: "user-1" }, + ); + }); + + it("rejects missing creator metadata", () => { + expect(() => parseApiKeyPrincipalMetadata({})).toThrow(z.ZodError); + }); +}); diff --git a/packages/validation/test/parse.spec.ts b/packages/validation/test/parse.spec.ts index 6b61a48eb..0ff2ac137 100644 --- a/packages/validation/test/parse.spec.ts +++ b/packages/validation/test/parse.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import * as z from "zod"; import { InvalidInput, parse, schemas } from "../src/index"; +import { workspaceGate } from "../src/workspace-gate"; const person = z.object({ name: z.string().trim().min(1), @@ -64,3 +65,16 @@ describe("the input request schema", () => { ).toBe(false); }); }); + +describe("the workspace gate schema", () => { + it("accepts an explicit no-organization result", () => { + expect( + workspaceGate.safeParse({ + organizationId: null, + slug: null, + onboarded: null, + canRename: null, + }).success, + ).toBe(true); + }); +}); diff --git a/patches/@better-auth%2Foauth-provider@1.7.3.patch b/patches/@better-auth%2Foauth-provider@1.7.3.patch new file mode 100644 index 000000000..2a1ac4a20 --- /dev/null +++ b/patches/@better-auth%2Foauth-provider@1.7.3.patch @@ -0,0 +1,18 @@ +diff --git a/dist/authorize-9whjxVLJ.mjs b/dist/authorize-9whjxVLJ.mjs +index 6c1d5c51c..47c085e76 100644 +--- a/dist/authorize-9whjxVLJ.mjs ++++ b/dist/authorize-9whjxVLJ.mjs +@@ -4404,7 +4404,12 @@ const oauthProvider = (options) => { + onRequest: handleIssuerMetadataRequest, + init: async (ctx) => { + if (ctx.options.secondaryStorage && ctx.options.session?.storeSessionInDatabase !== true) throw new BetterAuthError("OAuth Provider requires `session.storeSessionInDatabase: true` when using secondaryStorage"); +- await seedResources(ctx, opts); ++ try { ++ await seedResources(ctx, opts); ++ } catch (error) { ++ if (error?.code !== "ECONNRESET") throw error; ++ logger.warn("oauth-provider: database connection reset during startup; resource setup will retry on access."); ++ } + logEnforcePerClientResourcesResolution(opts); + if (!opts.disableJwtPlugin) { + const jwtPluginOptions = getJwtPlugin(ctx)?.options; diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index 7a4093828..000000000 --- a/skills-lock.json +++ /dev/null @@ -1,155 +0,0 @@ -{ - "version": 1, - "skills": { - "ai-elements": { - "source": "vercel/ai-elements", - "sourceType": "github", - "skillPath": "skills/ai-elements/SKILL.md", - "computedHash": "6d7ccc65b98adb04d6c47632746dbfea55528903de9b027f6b39b80983d5aa55" - }, - "ai-sdk": { - "source": "vercel/ai", - "sourceType": "github", - "skillPath": "skills/use-ai-sdk/SKILL.md", - "computedHash": "dfce60b0f2749991e000123526e298d5ede1c8353d837e71886fb3c8e5f8b730" - }, - "better-accessibility": { - "source": "jakubkrehel/skills", - "sourceType": "github", - "skillPath": "skills/better-accessibility/SKILL.md", - "computedHash": "a7b0b70403af4625052410d239a8683b35221efbf6b61b396110ce000da05e26" - }, - "better-auth-best-practices": { - "source": "better-auth/skills", - "sourceType": "github", - "skillPath": "better-auth/best-practices/SKILL.md", - "computedHash": "61ba0ef64ed2e7c424401cc848ca33dd6d790a720c44727717dc0c5cba5fc122" - }, - "better-colors": { - "source": "jakubkrehel/skills", - "sourceType": "github", - "skillPath": "skills/better-colors/SKILL.md", - "computedHash": "094fbf1d8e31d225179f5d3ca80246b08b3dd9154f4058d7ee86a95e1139e563" - }, - "better-interface": { - "source": "jakubkrehel/skills", - "sourceType": "github", - "skillPath": "skills/better-interface/SKILL.md", - "computedHash": "90e23bcfdea67e937a17ce43d91467f67ab71120803161ba0c2dabeb47066cd7" - }, - "better-layout": { - "source": "jakubkrehel/skills", - "sourceType": "github", - "skillPath": "skills/better-layout/SKILL.md", - "computedHash": "e4eda446af4127a9b0ea97f0cc1e7c77cc1120b91fce25e3947af5e0884b74e5" - }, - "better-typography": { - "source": "jakubkrehel/skills", - "sourceType": "github", - "skillPath": "skills/better-typography/SKILL.md", - "computedHash": "bbe024b4a78a56cd08b6d2e90d53bf268b559262c20665a440931645ed1861d4" - }, - "better-ui": { - "source": "jakubkrehel/skills", - "sourceType": "github", - "skillPath": "skills/better-ui/SKILL.md", - "computedHash": "3d745409bc2de890c04c110ea9d43a305b33abb500a5c664f26d44774a02c79f" - }, - "better-writing": { - "source": "jakubkrehel/skills", - "sourceType": "github", - "skillPath": "skills/better-writing/SKILL.md", - "computedHash": "450dc38b88fc7207044bf918e52a29422ad7a3de71517119cfd12fb65c7b05ee" - }, - "eve": { - "source": "vercel/eve", - "sourceType": "github", - "skillPath": "skills/eve/SKILL.md", - "computedHash": "4833349453c45c606f79202e495b7bbc42ea380f329f3beee36b9927763f19ef" - }, - "install-anti-slop": { - "source": "dmmulroy/anti-slop", - "sourceType": "github", - "skillPath": "skills/install-anti-slop/SKILL.md", - "computedHash": "b9c3852b1a4895ca33f92dd5015502bb75e36848e66f8767e125d37ea02e4b58" - }, - "nestjs-best-practices": { - "source": "kadajett/agent-nestjs-skills", - "sourceType": "github", - "skillPath": "skills/nestjs-best-practices/SKILL.md", - "computedHash": "58f8a630637be3acce1f118c747d68160346ada8a43d76cb52a5b4c4edaa2d37" - }, - "no-use-effect": { - "source": "factory-ai/factory-plugins", - "sourceType": "github", - "skillPath": "plugins/typescript/skills/no-use-effect/SKILL.md", - "computedHash": "3c4d9c9a65312482569bf3ad27d149c5c339567b52009f90ac29d212beee3766" - }, - "nuqs": { - "source": "pproenca/dot-skills", - "sourceType": "github", - "skillPath": "skills/.curated/nuqs/SKILL.md", - "computedHash": "5811f3b4c6f981aa75647ffb879d6e98429535ff830df219a04f4c9a884658a6" - }, - "posthog-instrumentation": { - "source": "posthog/posthog-for-claude", - "sourceType": "github", - "skillPath": "skills/posthog-instrumentation/SKILL.md", - "computedHash": "a9a30a63accd756ef41009f987ac75812ab1e855b30ea5d6153e0fed16ebf22d" - }, - "prisma-database-setup": { - "source": "prisma/skills", - "sourceType": "github", - "skillPath": "prisma-database-setup/SKILL.md", - "computedHash": "6a991a2530a6da7131e3fc71c353e8278702d0146b8b6e1d2fdfc2d3d0a21bd2" - }, - "seo-audit": { - "source": "coreyhaines31/marketingskills", - "sourceType": "github", - "skillPath": "skills/seo-audit/SKILL.md", - "computedHash": "f2dc52130189068981f21e1c0bdc4471ac7cd9b08d8a1320604411aa0f950385" - }, - "shadcn": { - "source": "shadcn/ui", - "sourceType": "github", - "skillPath": "skills/shadcn/SKILL.md", - "computedHash": "c1a68ee06a668aced9ab2b5fbdea5f989864123794eb2e056b339a072dbb7f10" - }, - "skill-creator": { - "source": "anthropics/skills", - "sourceType": "github", - "skillPath": "skills/skill-creator/SKILL.md", - "computedHash": "5ea13a6d9f0d4bb694405d79acd00cadec0d21bb138c4dd10fcf3c500cb835c2" - }, - "turborepo": { - "source": "vercel/turborepo", - "sourceType": "github", - "skillPath": "skills/turborepo/SKILL.md", - "computedHash": "f4b990564b013482af64f72ba2dd28f2e244283a1fdcf23e469b9bcf942c1212" - }, - "typescript-advanced-types": { - "source": "wshobson/agents", - "sourceType": "github", - "skillPath": "plugins/javascript-typescript/skills/typescript-advanced-types/SKILL.md", - "computedHash": "8cbf2bf600f392d7260e476e3202932a876e2281cda3b15a8b6a5a43f383252d" - }, - "vercel-composition-patterns": { - "source": "vercel-labs/agent-skills", - "sourceType": "github", - "skillPath": "skills/composition-patterns/SKILL.md", - "computedHash": "575757e3e25761c8c562d6e395d29f0b76c98b1273c0bd72d88e6ab1bc9c7d42" - }, - "vercel-react-best-practices": { - "source": "vercel-labs/agent-skills", - "sourceType": "github", - "skillPath": "skills/react-best-practices/SKILL.md", - "computedHash": "ca7b0c0c6e5f2750043f7f0cd72d16ac4e2abc48f9b5500d047a4b77a2506212" - }, - "web-design-guidelines": { - "source": "vercel-labs/agent-skills", - "sourceType": "github", - "skillPath": "skills/web-design-guidelines/SKILL.md", - "computedHash": "f3bc47f890f42a44db1007ab390709ec368e4b8c089baee6b0007182236ac474" - } - } -} diff --git a/tools/kaneo-dev.ts b/tools/kaneo-dev.ts new file mode 100644 index 000000000..b3fc8e682 --- /dev/null +++ b/tools/kaneo-dev.ts @@ -0,0 +1,175 @@ +import { existsSync, statSync } from "node:fs"; +import path from "node:path"; + +const DIST = path.join( + import.meta.dir, + "..", + "vendor", + "kaneo", + "apps", + "web", + "dist", +); +const API_DIR = path.join( + import.meta.dir, + "..", + "vendor", + "kaneo", + "apps", + "api", +); +const KANEO_PACKAGES = path.join( + import.meta.dir, + "..", + "vendor", + "kaneo", + "packages", +); +const API_ORIGIN = "http://127.0.0.1:1337"; +const PORT = 5173; + +function indexResponse(): Response { + return new Response(Bun.file(path.join(DIST, "index.html"))); +} + +function fileResponse(relativePath: string): Response { + const filePath = path.join(DIST, relativePath); + if ( + relativePath === "/" || + !existsSync(filePath) || + statSync(filePath).isDirectory() + ) { + return indexResponse(); + } + return new Response(Bun.file(filePath)); +} + +async function proxyRequest(req: Request): Promise { + const url = new URL(req.url); + const headers = new Headers(req.headers); + headers.delete("host"); + const body = ["GET", "HEAD"].includes(req.method) + ? undefined + : await req.arrayBuffer(); + const upstream = await fetch(`${API_ORIGIN}${url.pathname}${url.search}`, { + method: req.method, + headers, + body, + redirect: "manual", + }); + const responseHeaders = new Headers(upstream.headers); + responseHeaders.delete("content-encoding"); + return new Response(upstream.body, { + status: upstream.status, + headers: responseHeaders, + }); +} + +export function startKaneoDevServer() { + return Bun.serve({ + port: PORT, + websocket: { + open(ws) { + const { upstream } = ws.data as { upstream: WebSocket }; + upstream.addEventListener("message", (event) => { + ws.send(event.data); + }); + upstream.addEventListener("close", () => { + ws.close(); + }); + upstream.addEventListener("error", () => { + ws.close(); + }); + }, + message(ws, message) { + (ws.data as { upstream: WebSocket }).upstream.send(message); + }, + close(ws) { + (ws.data as { upstream: WebSocket }).upstream.close(); + }, + }, + async fetch(req, server) { + const url = new URL(req.url); + + if (url.pathname.startsWith("/ws")) { + const upstream = new WebSocket( + `ws://127.0.0.1:1337${url.pathname}${url.search}`, + ); + if (server.upgrade(req, { data: { upstream } })) { + return undefined; + } + upstream.close(); + return new Response("upgrade failed", { status: 500 }); + } + + if (url.pathname.startsWith("/api/")) { + return proxyRequest(req); + } + + return fileResponse(url.pathname); + }, + }); +} + +if (import.meta.main) { + const packagesWithDist = ["email", "permissions", "mcp", "planka-import"]; + for (const pkg of packagesWithDist) { + if (!existsSync(path.join(KANEO_PACKAGES, pkg, "dist", "index.js"))) { + console.log(`building @kaneo/${pkg}...`); + const built = Bun.spawnSync(["bunx", "tsc"], { + cwd: path.join(KANEO_PACKAGES, pkg), + env: process.env, + }); + if (built.exitCode !== 0) { + console.error(`failed to build @kaneo/${pkg}`); + process.exit(1); + } + } + } + + if (!existsSync(path.join(DIST, "index.html"))) { + console.log("building kaneo web..."); + const webBuild = Bun.spawnSync(["bun", "run", "build"], { + cwd: path.join(import.meta.dir, "..", "vendor", "kaneo", "apps", "web"), + env: { + ...process.env, + VITE_API_URL: "http://localhost:5173", + VITE_CLIENT_URL: "http://localhost:5173", + }, + }); + if (webBuild.exitCode !== 0) { + console.error("failed to build kaneo web"); + process.exit(1); + } + } + + const apiEnv: Record = {}; + for (const key of Object.keys(process.env)) { + apiEnv[key] = process.env[key] ?? ""; + } + const authSecret = process.env.BETTER_AUTH_SECRET; + if (authSecret && !apiEnv.AUTH_SECRET) { + apiEnv.AUTH_SECRET = authSecret; + } + apiEnv.KANEO_SKIP_DRIZZLE_MIGRATIONS = "1"; + apiEnv.KANEO_CLIENT_URL = "http://localhost:5173"; + apiEnv.CORS_ORIGINS = "http://localhost:5173"; + + const api = Bun.spawn(["bunx", "tsx", "src/index.ts"], { + cwd: API_DIR, + env: apiEnv, + stdout: "inherit", + stderr: "inherit", + }); + + const server = startKaneoDevServer(); + console.log(`kaneo dev web: http://localhost:${server.port} → ${API_ORIGIN}`); + + const shutdown = () => { + api.kill(); + server.stop(true); + process.exit(0); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} diff --git a/turbo.json b/turbo.json index 110f9bd94..ee963ebbc 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://turborepo.dev/schema.json", + "$schema": "https://v2-10-12.turborepo.dev/schema.json", "ui": "tui", "globalEnv": ["NODE_ENV"], "globalPassThroughEnv": [ @@ -26,10 +26,36 @@ "PERPLEXITY_API_KEY", "GITHUB_TOKEN", "BLOB_READ_WRITE_TOKEN", + "R2_ACCOUNT_ID", + "R2_ACCESS_KEY_ID", + "R2_SECRET_ACCESS_KEY", + "R2_BUCKET", "AI_GATEWAY_API_KEY", "VERCEL_OIDC_TOKEN", "AGENT_URL", "AGENT_BRIDGE_SECRET", + "XMPP_COMPONENT_ENABLED", + "XMPP_COMPONENT_JID", + "XMPP_COMPONENT_SECRET", + "XMPP_COMPONENT_SERVICE", + "XMPP_ORGANIZATION_ID", + "XMPP_DEFAULT_AGENT_JID", + "XMPP_AGENT_DOMAIN", + "XMPP_SERVER_DOMAIN", + "XMPP_GATEWAY_ID", + "XMPP_AGENT_VERSION", + "XMPP_ALLOWED_CALLER_DOMAINS", + "XMPP_ALLOW_DESTRUCTIVE_CALLERS", + "XMPP_XML_LANG", + "XMPP_RECEIPT_TIMEOUT_MS", + "XMPP_RECEIPT_MAX_RESENDS", + "XMPP_RECEIPT_SWEEP_MS", + "XMPP_RECONNECT_INITIAL_MS", + "XMPP_RECONNECT_MAX_MS", + "XMPP_PING_INTERVAL_MS", + "XMPP_PING_TIMEOUT_MS", + "XMPP_PING_FAILURE_THRESHOLD", + "XMPP_MAX_PENDING_IQ_REQUESTS", "CRM_TELEMETRY_DISABLED", "DO_NOT_TRACK", "VERCEL", diff --git a/vendor/FORK-DELTA.md b/vendor/FORK-DELTA.md new file mode 100644 index 000000000..84b38be98 --- /dev/null +++ b/vendor/FORK-DELTA.md @@ -0,0 +1,65 @@ +# Kaneo fork delta + +The fork is the trunk. Upstream is a drain. All work lands here first, and generic +improvements are donated upstream as separate pull requests so that upstream merges +shrink this delta instead of growing it. Nothing waits on upstream. + +## Source + +Kaneo is a git **submodule** at `vendor/kaneo`, pointing at a branch on the fork. +The deltas below live in that branch, not in this repository. + +- Fork: https://github.com/romanbsd/kaneo +- Branch: `crm-integration` +- Pinned commit: `bfa867bba54d8f2ec0a8e376fe4f4b81969dd97c` +- Based on upstream: `46539164c68669cec15b1528835c10ad0a66355e` + +## Updating the submodule + +Rebase the fork branch on upstream main, then bump the pointer here: + +```sh +git -C vendor/kaneo fetch origin +git -C vendor/kaneo checkout crm-integration +git -C vendor/kaneo merge origin/main # resolve deltas, if any +git -C vendor/kaneo push origin crm-integration +git -C vendor/kaneo log --oneline -1 +git add vendor/kaneo # records the new commit +``` + +A fresh checkout needs the submodule initialized and kaneo's dependencies +installed before `dev:kaneo` runs: + +```sh +git submodule update --init vendor/kaneo +cd vendor/kaneo && bun install +``` + +## Rules + +- `vendor/` is outside the bun workspaces on purpose. The root `turbo.json` and + `package.json` do not see it, so a broken Kaneo build cannot take down the CRM's. + When a piece of Kaneo is brought into the CRM build, it is extracted first and wired + into `packages/*` or `apps/*` on its own. +- Generic packages (the domain model, any binding) never import `@crm/*`, never read a + constant from the CRM, never assume a single tenant. A change that trips that rule is + fork-specific and stays here. +- One root `.env`. No per-package `.env`. +- A bundled Kaneo feature is an optional capability: a missing key removes the feature, + never throws. + +## Delta log + +A change in this file, with its reason. Generic improvements are tracked as upstream +pull requests; only fork-specific or unmerged changes are listed. + +| Change | Reason | Upstream PR | +| --- | --- | --- | +| Removed `apps/web/.env.development` and `.env.production` | One root `.env` rule; per-app env files are placeholders that invite confusion | — | +| Renamed `activity` table to `task_activity` in `apps/api/src/database/schema.ts` | Collides with the CRM's live `activity` timeline table in the one shared schema; the CRM's keeps the name | — | +| Renamed `invitation` table and its indexes to `workspace_invitation*` in `apps/api/src/database/schema.ts` | Collides with the CRM's better-auth org-plugin `invitation` table and its auto-named indexes | — | +| Renamed shared auth table columns (`user`, `session`, `account`, `verification`, `apikey`) to camelCase in `apps/api/src/database/schema.ts` | The CRM owns these tables with camelCase physical names; kaneo's auth reads them | — | +| Gated the startup Drizzle migrations and schema utilities behind `KANEO_SKIP_DRIZZLE_MIGRATIONS` in `apps/api/src/index.ts` | Prisma owns the schema; kaneo's own migrator must not run against the shared database | — | +| Set `advanced.cookiePrefix: "crm"` in `apps/api/src/auth.ts` | Shares the CRM's session cookie; one session token valid at both apps (same secret and session table) | — | +| Dropped `team.member_count` and `team_member.membership_key` from drizzle | Prisma `Team`/`TeamMember` never stored them; drizzle now matches the shared schema | — | +| `resolve-database-url` prefers `TEST_DATABASE_URL` | Kaneo's Drizzle and the CRM's Prisma share the test database under the repo test convention | — | diff --git a/vendor/kaneo b/vendor/kaneo new file mode 160000 index 000000000..ec78c8b69 --- /dev/null +++ b/vendor/kaneo @@ -0,0 +1 @@ +Subproject commit ec78c8b695d565aa1dba71859ccb6173861ce3de