diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 292829a4..bc09182b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,24 @@ on: branches: [master, main] jobs: + release-scope: + name: Release Scope + runs-on: ubuntu-latest + outputs: + release_scope: ${{ steps.scope.outputs.scope }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Validate commit-pinned release scope + id: scope + run: node scripts/resolve-release-scope.mjs --github-output + typecheck: name: Type Check runs-on: ubuntu-latest + needs: release-scope steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6.0.10 @@ -26,6 +41,8 @@ jobs: run: pnpm --filter @deft/app-kit test - name: Verify release publishing contract run: pnpm test:release-workflow + - name: Verify container process supervision + run: node --test scripts/docker-entrypoint.test.mjs - name: Verify deterministic Hermes integration bundle run: pnpm test:hermes-integration-bundle - name: Lint web @@ -38,6 +55,15 @@ jobs: src/lib/space-socket.test.ts src/lib/module-collection-nav.test.ts src/lib/apps.test.ts + src/lib/api-retry.test.ts + src/lib/api-auth.test.ts + src/lib/editor-attribute-security.test.ts + src/lib/native-create-intent.test.ts + src/lib/time.test.ts + src/lib/calendar-event-form.test.ts + 'src/app/(app)/notes/note-save-coordinator.test.ts' + 'src/app/(app)/notes/protected-note-image.test.ts' + 'src/app/(app)/notes/note-load-state.test.ts' - name: Type check API run: pnpm --filter @deft/api typecheck - name: Type check Web @@ -104,7 +130,8 @@ jobs: hermes-release-gate: name: Hermes Employee Release Gate runs-on: ubuntu-latest - needs: typecheck + needs: [release-scope, typecheck] + if: needs.release-scope.outputs.release_scope == 'hermes-certified' services: postgres: image: pgvector/pgvector:pg16 @@ -183,7 +210,13 @@ jobs: build: name: Build runs-on: ubuntu-latest - needs: [typecheck, test, hermes-release-gate] + needs: [release-scope, typecheck, test, hermes-release-gate] + if: >- + always() && !cancelled() && + needs.release-scope.result == 'success' && + needs.typecheck.result == 'success' && + needs.test.result == 'success' && + (needs.release-scope.outputs.release_scope == 'core' || needs.hermes-release-gate.result == 'success') steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6.0.10 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fba9e812..9a97bbef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,9 +31,29 @@ env: IMAGE_NAME: maneek21/deft jobs: + scope: + name: Resolve release scope + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + release_scope: ${{ steps.scope.outputs.scope }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + - uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Validate commit-pinned release scope + id: scope + run: node scripts/resolve-release-scope.mjs --github-output + certify: name: Certify Hermes employee release runs-on: ubuntu-latest + needs: scope + if: needs.scope.outputs.release_scope == 'hermes-certified' permissions: contents: read services: @@ -142,7 +162,11 @@ jobs: publish: name: Publish release image runs-on: ubuntu-latest - needs: certify + needs: [scope, certify] + if: >- + always() && !cancelled() && + needs.scope.result == 'success' && + (needs.scope.outputs.release_scope == 'core' || needs.certify.result == 'success') steps: - uses: actions/checkout@v7 with: @@ -178,12 +202,14 @@ jobs: echo "sha=$tag_commit" >> "$GITHUB_OUTPUT" - name: Download Hermes employee certification + if: needs.scope.outputs.release_scope == 'hermes-certified' uses: actions/download-artifact@v4 with: name: hermes-employee-release-certification path: dist - name: Verify carried Hermes certificate and bundle + if: needs.scope.outputs.release_scope == 'hermes-certified' env: RELEASE_TAG: ${{ steps.release.outputs.tag }} RELEASE_VERSION: ${{ steps.release.outputs.version }} @@ -310,6 +336,7 @@ jobs: run: | mkdir -p dist cp docker-compose.yml compose.prod.yml compose.release.yml LICENSE NOTICE THIRD-PARTY-LICENSES.md dist/ + cp docs/self-hosting.md dist/self-hosting.md # GitHub release assets cannot retain a leading dot reliably. Publish # the template under a stable explicit name so downloaded checksums # match the asset name on every client. @@ -332,6 +359,7 @@ jobs: output-file: dist/deft-${{ steps.release.outputs.version }}.spdx.json - name: Archive and reverify the carried Hermes integration + if: needs.scope.outputs.release_scope == 'hermes-certified' env: HERMES_BUNDLE_EVIDENCE_PATH: ${{ runner.temp }}/hermes-bundle-evidence.json shell: bash @@ -360,6 +388,7 @@ jobs: - name: Generate release manifest env: + RELEASE_SCOPE_PATH: release/release-scope.json RELEASE_TAG: ${{ steps.release.outputs.tag }} RELEASE_VERSION: ${{ steps.release.outputs.version }} RELEASE_COMMIT: ${{ steps.release.outputs.sha }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e12baf2..47c00d30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,54 @@ env vars. Patch versions (`0.X.Y`) are non-breaking fixes only. ## [Unreleased] +Planned for the v0.3.0-preview.15 core preview. Publication is pending release checks. + +### Added + +- Connected Apps can request access to supported workspace resources and run + bounded scheduled actions after operator review. App Kit includes the matching + package and authoring support. These experimental features are disabled by + default; see [product status](docs/product-status.md) before enabling them. + +### Fixed + +- Retrying a task, project, event or message after a lost response no longer + creates a duplicate of the same request. +- Notes retain pending edits through interrupted saves and refresh expired image + access. Unavailable private notes show a recoverable error instead of an empty + editor. +- Native calendar creation and editing use the profile timezone consistently; + editing targets the existing event. Task date controls support keyboard use. +- Chat attachments appear once in the composer, and search previews show readable + text rather than stored HTML. +- Connections wait for initialization before joining chat rooms, and background + queues wait until storage is ready. Automation lists use fewer database queries. +- Approved task creation records its result. Invalid App uninstall requests + return a useful client error instead of a server failure. +- Backups and upgrades preserve recovery assets; container process supervision + propagates startup failures and shutdown correctly. + +### Security + +- Browser sessions rotate durable refresh credentials and invalidate outstanding + credentials on password changes. Public OAuth endpoints have bounded requests. +- Tiptap dependencies are updated to 3.30.4, including the fix for + [GHSA-cp6q-959q-f8rh](https://github.com/advisories/GHSA-cp6q-959q-f8rh). + +### Release and upgrade notes + +- The next preview is a **core** release. Hermes is not certified or + distributed as a supported integration bundle for this candidate. Existing + integration source and historical certification records are not new proof. +- The schema advances to `0.3.0-preview.29`, including durable browser-session + state and scoped native-create identities. Back up first and use the documented + versioned upgrade path; do not run fresh-install initialization on existing data. +- Keep the previous image, database backup, uploads, and encryption secrets + together for recovery. Image rollback alone is not a database rollback. +- Images remain Linux amd64 previews. This release does not claim long-duration + soak coverage or Hermes certification. Read the release's verification results + and [current limitations](docs/current-limitations.md) before deployment. + ## [0.3.0-preview.14] — 2026-08-31 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb23ec90..fe59e580 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,8 +14,8 @@ Deft welcomes focused bug fixes, tests, documentation, accessibility improvement ### Requirements -- Node.js 20 or newer (22 recommended) -- pnpm 9 or newer +- Node.js 22.13.0 or newer +- pnpm 11.10.0 (pinned in `package.json`) - PostgreSQL 16 with pgvector ```bash diff --git a/FEATURES.md b/FEATURES.md index 6a066a31..0601a4a2 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,6 +1,6 @@ # Deft capability reference -> Last verified against the repository on July 16, 2026. +> Availability last reconciled on September 7, 2026. The latest published image is `v0.3.0-preview.14`; features present on `master` may belong to the unpublished `v0.3.0-preview.15` core candidate. See the [availability map](docs/product-status.md). > > Deft is an alpha. This file describes the current product surface, not a compatibility guarantee. See [current limitations](docs/current-limitations.md) and the [roadmap](ROADMAP.md) before planning a production deployment. @@ -113,7 +113,7 @@ Agent employees are separate workspace identities backed by a customer-controlle - Operate under trust level, scope, health, action-cap, audit, and approval rules - Expose supervision state, recent contact, failures, and bridge health to admins -Deft does not require a specific agent framework. A compatible runtime can be built with Hermes, Codex, Claude, or another streamable HTTP MCP client. The external runtime is operated separately from the Deft application stack. +Deft does not require a specific agent framework. A compatible customer-operated runtime can use the streamable HTTP MCP endpoint. Hermes support is release-specific historical compatibility; it is excluded from the upcoming core preview and is not a new compatibility claim. ## Personal AI app connections @@ -174,7 +174,14 @@ Provider keys are optional. Core workspace functionality remains available witho - Production guidance for VPS, domain, HTTPS, and reverse proxy setup - Synthetic 60-person certification tooling for isolation, bulk operations, job backlog, notification volume, and recovery exercises -Fresh installs currently use `pnpm db:push-full`. A supported versioned upgrade workflow is still deferred; see [current limitations](docs/current-limitations.md). +Fresh installs use `pnpm db:push-full`. Supported release-to-release upgrades use `pnpm db:upgrade` from the `v0.2.0-preview.1` baseline; see [current limitations](docs/current-limitations.md). + +## Apps and Modules + +- Modules define domain records, relationships, and Deft-rendered native views. +- Declarative internal Apps package Modules for review and installation and are an opt-in alpha capability. +- Connected Apps and bounded daily actions are implemented on `master` for the upcoming core candidate. They are experimental, disabled by default, and require the flags and review flow in the [operator guide](docs/app-run-operations.md). +- The current App protocols do not provide arbitrary custom UI, public portals, general external runtimes, or synchronization. ## Security posture diff --git a/README.md b/README.md index d196f404..6efd5fe8 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ [Website](https://deft.ing) | [Self-hosting guide](docs/self-hosting.md) | [Contributing](CONTRIBUTING.md) +**Try Deft:** [install a workspace, connect your AI client, or build an internal App](docs/getting-started.md). The latest downloadable image is `v0.3.0-preview.14`; the `v0.3.0-preview.15` core candidate described on `master` has not been published. See the [current availability map](docs/product-status.md) before evaluating an unreleased feature. + ![Where humans and agents work together.](docs/assets/repository/hero.png) Deft is a self-hostable, open-source workspace where people and AI agents share the same chat, tasks, knowledge, calendar context, approvals, and receipts. @@ -18,9 +20,9 @@ Instead of pasting fragments from Slack, Notion, and a task tracker into an AI c 1. **Work happens in context.** People discuss an issue in chat, update a task, write a note, or record a decision. 2. **An agent reads the same workspace.** Defty, an agent employee, or a personal MCP client can retrieve the relevant messages, tasks, wiki pages, people, and calendar context. -3. **Writes stay governed.** Risky changes are drafted first and shown as approval cards in the conversation and approval inbox. +3. **Governed actions can require review.** Employee actions follow their approval policy, with proposed changes shown in conversation cards and the approval inbox. Personal MCP writes use the authorizing person's permissions and scopes. 4. **The result lands in Deft.** Tasks, messages, notes, wiki pages, and status changes become part of the shared record. -5. **Every action leaves a receipt.** The workspace records who or what acted, what changed, and where the result lives. +5. **Governed agent actions leave receipts.** Deft records the actor, outcome, and result for actions that pass through its governed execution paths. ![A live Deft workspace](docs/assets/repository/dashboard.png) @@ -46,9 +48,9 @@ Instead of pasting fragments from Slack, Notion, and a task tracker into an AI c Deft still works as a normal workspace without an AI provider key. Chat, tasks, notes, knowledge, calendar, people, and teams remain available; AI features stay disabled until a provider is configured. -### An extensible workspace through Modules +### An extensible workspace through Modules and Apps -Deft's workspace can also grow with the needs of a company. Modules let developers add domain-specific collections and applications while inheriting the workspace primitives that already exist, including identity, permissions, search, context and agent access. +Modules add domain records, relationships, and native views. Apps package supported workspace extensions for operator review and installation. Declarative internal Apps are an opt-in alpha capability; connected Apps and bounded scheduled actions are implemented in the upcoming core candidate and remain disabled by default. Arbitrary custom UI and public portals are planned rather than part of the current contract. The bundled **Contacts** module is the first example of this model. The goal is not to turn Deft's core into every application a company might need, but to let new capabilities live on the same shared substrate instead of becoming another disconnected system. @@ -102,13 +104,14 @@ cd Deft cp .env.example .env ``` -Set the three required secrets in `.env`: +Set the four required secrets in `.env`: | Variable | Generate with | |---|---| | `POSTGRES_PASSWORD` | `openssl rand -hex 32` | | `JWT_SECRET` | `openssl rand -hex 32` | | `JWT_REFRESH_SECRET` | `openssl rand -hex 32` | +| `ENCRYPTION_KEY` | `openssl rand -hex 32` | Then build, start, initialize, and verify the stack: diff --git a/RELEASING.md b/RELEASING.md index 6df02bce..dc78c87a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -44,28 +44,30 @@ Releases must come off a green `master`. ### 2. Open a release-prep PR ```bash -git switch -c chore/release-vX.Y.Z-channel +git switch -c codex/release-vX.Y.Z-channel ``` -Edit `package.json` (root) — bump the `version` field. Workspace -`package.json`s under `apps/` and `packages/` intentionally do **not** -carry independent versions; they all inherit the monorepo version. - -Edit `CHANGELOG.md`: -- Write an accurate **delta from the previous tag**. Do not dump the - entire `[Unreleased]` section if it still contains work that already - shipped on an earlier tag. -- Leave a fresh empty `[Unreleased]` section at the top. -- Update the comparison links at the bottom of the file: - ``` - [Unreleased]: https://github.com/Maneek21/Deft/compare/vX.Y.Z-channel...HEAD - [X.Y.Z-channel]: https://github.com/Maneek21/Deft/releases/tag/vX.Y.Z-channel - ``` - -If README or compose files still contain a placeholder image tag, replace -it with the GHCR tag (no leading `v`) in the same PR. Merge the prep PR -and push the annotated tag in the same release session so README never -points at a missing image. +Edit `package.json` (root) to set the product version. Do not bulk-rewrite +workspace package versions: packages such as `@deft/app-kit` have their own +versioned contracts. Authoring instructions must use a package version from +the same release as the host. + +Set `release/release-scope.json` in the same prep commit. Use `core` for a +standard Deft release with no Hermes compatibility claim. Use +`hermes-certified` only when the tag must carry the certified Hermes integration; +that scope requires the exact pinned-runtime, two-pass certificate and bundle. +The workflow rejects missing or unknown scope decisions and never falls back from +`hermes-certified` to `core` when certification evidence is absent. + +Edit `CHANGELOG.md` with the actual delta from the previous tag. Keep the +candidate notes under `[Unreleased]` and explicitly mark publication as pending. +Do not carry over changes that already shipped in the previous release. + +Keep public install commands pointed at the latest published image. After the +release workflow succeeds and its assets are verified, make a small documentation +PR to move the candidate notes into a dated version section, restore an empty +`[Unreleased]` section, and update comparison links and installation examples. +This keeps the documentation usable while release checks are running. Commit, push, open a PR titled `chore(release): vX.Y.Z-channel`, wait for required CI (including Dependency Review), squash-merge. @@ -89,16 +91,17 @@ Use **annotated** tags (`-a`), never lightweight tags. Pushing a `v*` tag runs [`.github/workflows/release.yml`](.github/workflows/release.yml). That workflow: -- provisions the exact manifest-pinned Hermes runtime and requires two - consecutive clean-state employee gate passes from the release tag +- reads and validates the release scope from the tagged commit +- for `hermes-certified`, provisions the exact manifest-pinned Hermes runtime and + requires two consecutive clean-state employee gate passes from the release tag - builds and pushes the `linux/amd64` GHCR image - publishes GitHub build provenance and keylessly signs the exact image digest - verifies the Cosign workflow identity and provenance before continuing -- attaches the SPDX SBOM, corresponding source, certified Hermes integration - archive, and `hermes-employee-release-gate.json` -- writes `release-manifest.json` (`license: AGPL-3.0-only`) with the digest, - signing identity, provenance type, Hermes compatibility/tested runtime, and - certificate/archive/bundle digests +- attaches the SPDX SBOM and corresponding source; `hermes-certified` releases + also attach the certified Hermes integration archive and certificate +- writes the `deft.release.v2` `release-manifest.json` (`license: AGPL-3.0-only`) with the digest, + signing identity, provenance type, and explicit release scope; certified + manifests also record Hermes compatibility, tested runtime, and evidence digests - creates the GitHub Release (`--generate-notes`, prerelease when the version contains `-`) @@ -114,15 +117,17 @@ tag-signed digest without rebuilding it. Do not select reuse when the original image/signature steps did not complete. If the published image itself is unusable, fix forward and cut the next preview tag. -Confirm the GitHub Release includes `LICENSE`, `NOTICE`, +Confirm every GitHub Release includes `LICENSE`, `NOTICE`, `THIRD-PARTY-LICENSES.md`, `default.env.example`, the source archive, SBOM, -checksums, compose files, `hermes-employee-release-gate.json`, and the -versioned Hermes integration archive. Confirm the image label +checksums, compose files, and `self-hosting.md`. For `hermes-certified`, also +confirm `hermes-employee-release-gate.json` and the versioned Hermes integration +archive are present. For `core`, confirm those Hermes artifacts and all `hermes_*` +manifest claims are absent. Confirm the image label `org.opencontainers.image.licenses=AGPL-3.0-only` (the production `Dockerfile` sets this; `release.yml` passes `VCS_REF` and `SOURCE_URL`). -Verify the Hermes certificate reports the exact tag commit and two clean-state -passes, then compare its certificate and bundle digests with -`release-manifest.json` and `SHA256SUMS`. +For a certified release, verify the Hermes certificate reports the exact tag +commit and two clean-state passes, then compare its certificate and bundle digests +with `release-manifest.json` and `SHA256SUMS`. Run the digest-first Cosign and `gh attestation verify` commands in [`docs/self-hosting.md`](docs/self-hosting.md) against the published manifest. If signing, signature verification, provenance publication, or provenance diff --git a/ROADMAP.md b/ROADMAP.md index 18050f00..4b0828c9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,14 +11,12 @@ This roadmap communicates direction, not delivery dates. Deft is an alpha and pr - Replace stale repository claims with current product proof - Keep permission, org isolation, and private-space tests mandatory -## Next preview: make upgrades and releases repeatable +## Upcoming core preview -- Support versioned database upgrades from the previous preview -- Publish signed or checksummed release artifacts and a GHCR image -- Add release notes that distinguish fresh installs from upgrades -- Add browser smoke, dependency review, CodeQL, container scanning, and SBOM generation to release gates -- Certify backup, upgrade, rollback, and restore on the supported Docker Compose path -- Expand import/export and operator recovery guidance +- Publish the prepared `v0.3.0-preview.15` core candidate after its release gates pass +- Ship connected Apps and bounded scheduled actions as experimental, opt-in capabilities +- Keep the release explicitly core-scoped; Hermes certification and its bundle are excluded +- Publish operational release notes, checksums, provenance, SBOM, and the exact tested revision ## Before stable v1 @@ -36,6 +34,7 @@ This roadmap communicates direction, not delivery dates. Deft is an alpha and pr - Additional calendar and external-tool pathways through customer-owned MCP runtimes - Richer team analytics and administrative audit controls - Ecosystem work around reusable agent skills and templates +- Arbitrary App custom UI, public portals, general external runtimes, and synchronization after their identity and authorization contracts are defined ## Explicit non-commitments diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 00069175..17e69a3a 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -99,6 +99,7 @@ app.use('*', cors({ ], credentials: true, allowHeaders: [ + 'Idempotency-Key', 'Content-Type', 'Authorization', 'x-deft-audit-token', @@ -131,6 +132,8 @@ app.use('/api/auth/login', loginIpLimiter); app.use('/api/auth/signup', authLimiter); app.use('/api/auth/refresh', authLimiter); app.use('/api/auth/logout', authLimiter); +app.use('/api/auth/forgot-password', authLimiter); +app.use('/api/auth/reset-password', authLimiter); app.use('/api/auth/has-workspace', defaultLimiter); app.use('/api/auth/me', defaultLimiter); app.use('/api/auth/onboarding', defaultLimiter); diff --git a/apps/api/src/lib/agent-channel.ts b/apps/api/src/lib/agent-channel.ts index be118bee..f45dbf07 100644 --- a/apps/api/src/lib/agent-channel.ts +++ b/apps/api/src/lib/agent-channel.ts @@ -45,9 +45,9 @@ export const AGENT_CHANNEL_AUTONOMOUS_REQUIRED_RUNTIME_CAPABILITIES = [ 'accepted_event_rehydration_v1', ] as const; export type AgentChannelAdapterMode = 'supervised_runtime' | 'autonomous_platform'; -export const DEFT_RELEASE_VERSION = process.env.DEFT_RELEASE_VERSION || '0.3.0-preview.14'; +export const DEFT_RELEASE_VERSION = process.env.DEFT_RELEASE_VERSION || '0.3.0-preview.15'; export const DEFT_BUILD_COMMIT = process.env.DEFT_BUILD_COMMIT || process.env.VCS_REF || 'unknown'; -export const DEFT_SCHEMA_HEAD = '0.3.0-preview.14'; +export const DEFT_SCHEMA_HEAD = '0.3.0-preview.29'; export const AGENT_CHANNEL_DEFAULT_LEASE_MS = 120_000; export const AGENT_CHANNEL_MIN_LEASE_MS = 30_000; export const AGENT_CHANNEL_MAX_LEASE_MS = 600_000; diff --git a/apps/api/src/lib/app-automation-definition-service.ts b/apps/api/src/lib/app-automation-definition-service.ts index f1055b97..e9d0976d 100644 --- a/apps/api/src/lib/app-automation-definition-service.ts +++ b/apps/api/src/lib/app-automation-definition-service.ts @@ -41,6 +41,7 @@ import { terminalizeUnclaimedAppAutomationFireMisfireWithExecutor, transitionAppAutomationDefinitionWithExecutor, type AppAutomationDefinitionRow, + type AppAutomationDefinitionListCursor, type AppAutomationFireRow, } from './app-automation-repository.js'; @@ -742,7 +743,11 @@ export async function getAppAutomationDefinition( export async function listAppAutomationDefinitions( actor: ModuleActor, - input: Readonly<{ app_installation_id?: string; limit?: number }> = {}, + input: Readonly<{ + app_installation_id?: string; + limit?: number; + after?: AppAutomationDefinitionListCursor; + }> = {}, ): Promise { return db.transaction(async (tx) => { await assertManager(tx, actor); @@ -750,6 +755,7 @@ export async function listAppAutomationDefinitions( organization_id: actor.org_id, app_installation_id: input.app_installation_id, limit: Math.max(1, Math.min(APP_AUTOMATION_FOUNDATION_LIMITS.list_limit, input.limit ?? 50)), + after: input.after, }); }); } diff --git a/apps/api/src/lib/app-automation-management-service.ts b/apps/api/src/lib/app-automation-management-service.ts index ac37c006..5a62da8f 100644 --- a/apps/api/src/lib/app-automation-management-service.ts +++ b/apps/api/src/lib/app-automation-management-service.ts @@ -24,6 +24,75 @@ import { digestAppGrantValue } from './app-grant-service.js'; const KeySchema = z.string().regex(/^[a-z][a-z0-9_]{0,47}$/) .refine((value) => !/^(deft|core|system)(_|$)/.test(value)); +const ManagementCursorSchema = z.string().min(1).max(512); +const MANAGEMENT_PAGE_LIMIT = 50; + +export type AppAutomationManagementCursor = Readonly<{ + app_installation_id: string; + created_at: Date; + id: string; +}>; + +export function encodeAppAutomationManagementCursor(definition: AppAutomationManagementCursor): string { + return Buffer.from(JSON.stringify({ + app_installation_id: definition.app_installation_id, + created_at: definition.created_at.toISOString(), + id: definition.id, + })).toString('base64url'); +} + +export function decodeAppAutomationManagementCursor( + value: string | undefined, + appInstallationId: string, +): AppAutomationManagementCursor | undefined { + if (!value) return undefined; + try { + const parsed = JSON.parse(Buffer.from(ManagementCursorSchema.parse(value), 'base64url').toString('utf8')) as { + app_installation_id?: unknown; + created_at?: unknown; + id?: unknown; + }; + const createdAt = typeof parsed.created_at === 'string' ? new Date(parsed.created_at) : null; + if ( + !createdAt + || Number.isNaN(createdAt.getTime()) + || parsed.app_installation_id !== appInstallationId + || typeof parsed.id !== 'string' + || !parsed.id.trim() + ) { + throw new Error('invalid cursor'); + } + return { app_installation_id: appInstallationId, created_at: createdAt, id: parsed.id }; + } catch { + throw new AppError('Invalid App automation cursor', 'APP_ACTION_INVALID', 400); + } +} + +export function selectAppAutomationManagementPage(definitions: readonly T[], limit: number) { + const page = definitions.slice(0, limit); + return { + page, + next_cursor: definitions.length > page.length && page.length > 0 + ? encodeAppAutomationManagementCursor(page[page.length - 1]!) + : null, + }; +} + +export function projectAppAutomationManagementEligibility( + definition: Pick, + now: Date, + enabled: boolean, +) { + if (!enabled) return { status: 'delivery_disabled' as const, reason: 'Scheduled delivery is disabled by the host kill switch.' }; + if (definition.state !== 'active') return { status: definition.state, reason: `Definition is ${definition.state}.` }; + if (now >= definition.valid_until) return { status: 'expired' as const, reason: 'The approved validity window ended; create a freshly reviewed definition.' }; + if (now < definition.valid_from) return { status: 'waiting' as const, reason: 'Waiting for the approved validity window to begin.' }; + return { status: 'awaiting_delivery_check' as const, reason: 'Schedule time is eligible; pinned authority and resources are rechecked before delivery.' }; +} const AutomationActionInputSchema = AppBindingInvokeInputSchema.omit({ idempotency_key: true, user_inputs: true, @@ -214,25 +283,34 @@ function projectDefinition(definition: AppAutomationDefinitionRow) { export async function listManagedAppAutomations( actor: ModuleActor, appInstallationId: string, + input: Readonly<{ cursor?: string; limit?: number }> = {}, now = new Date(), ) { + const limit = Math.max(1, Math.min(MANAGEMENT_PAGE_LIMIT, input.limit ?? MANAGEMENT_PAGE_LIMIT)); const definitions = await listAppAutomationDefinitions(actor, { app_installation_id: appInstallationId, - limit: 50, + limit: limit + 1, + after: decodeAppAutomationManagementCursor(input.cursor, appInstallationId), }); - const definitionIds = definitions.map((definition) => definition.id); + const { page, next_cursor: nextCursor } = selectAppAutomationManagementPage(definitions, limit); + const definitionIds = page.map((definition) => definition.id); const latestFires = new Map(); const fireCounts = new Map>(); const runs = new Map>(); if (definitionIds.length > 0) { - for (const definitionId of definitionIds) { - const [latest] = await db.select().from(appAutomationFires).where(and( + const latestRows = await db.selectDistinctOn([appAutomationFires.definition_id]) + .from(appAutomationFires) + .where(and( eq(appAutomationFires.org_id, actor.org_id), - eq(appAutomationFires.definition_id, definitionId), - )).orderBy(desc(appAutomationFires.created_at), desc(appAutomationFires.id)).limit(1); - if (latest) latestFires.set(definitionId, latest); - } + inArray(appAutomationFires.definition_id, definitionIds), + )) + .orderBy( + appAutomationFires.definition_id, + desc(appAutomationFires.created_at), + desc(appAutomationFires.id), + ); + for (const latest of latestRows) latestFires.set(latest.definition_id, latest); const counts = await db.select({ definition_id: appAutomationFires.definition_id, state: appAutomationFires.state, @@ -268,7 +346,7 @@ export async function listManagedAppAutomations( enabled: APP_AUTOMATIONS_ENABLED, status: APP_AUTOMATIONS_ENABLED ? 'enabled' as const : 'disabled' as const, }, - definitions: definitions.map((definition) => { + definitions: page.map((definition) => { const latest = latestFires.get(definition.id) ?? null; const run = latest?.app_run_id ? runs.get(latest.app_run_id) ?? null : null; const counts = fireCounts.get(definition.id) ?? {}; @@ -284,11 +362,17 @@ export async function listManagedAppAutomations( eligible_before: definition.valid_until, }) : null; + const eligibility = projectAppAutomationManagementEligibility( + definition, + now, + APP_AUTOMATIONS_ENABLED, + ); return { ...projectDefinition(definition), next_fire_at_utc: next?.resolution.kind === 'resolved' ? next.resolution.resolved_at_utc.toISOString() : null, + eligibility, fire_summary: { pending: counts.pending ?? 0, claimed: counts.claimed ?? 0, @@ -319,6 +403,7 @@ export async function listManagedAppAutomations( }, }; }), + next_cursor: nextCursor, }; } diff --git a/apps/api/src/lib/app-automation-repository.ts b/apps/api/src/lib/app-automation-repository.ts index e93ef1d0..534bde8c 100644 --- a/apps/api/src/lib/app-automation-repository.ts +++ b/apps/api/src/lib/app-automation-repository.ts @@ -41,6 +41,11 @@ type AutomationExecutor = Pick; + export type AppAutomationDefinitionScanCursor = Readonly<{ organization_id: string; definition_id: string; @@ -71,16 +76,26 @@ export async function listAppAutomationDefinitionsWithExecutor( organization_id: string; app_installation_id?: string; limit: number; + after?: AppAutomationDefinitionListCursor; }>, ): Promise { - const where = input.app_installation_id + const scope = input.app_installation_id ? and( eq(appAutomationDefinitions.org_id, input.organization_id), eq(appAutomationDefinitions.app_installation_id, input.app_installation_id), ) : eq(appAutomationDefinitions.org_id, input.organization_id); + const cursor = input.after + ? or( + lt(appAutomationDefinitions.created_at, input.after.created_at), + and( + eq(appAutomationDefinitions.created_at, input.after.created_at), + lt(appAutomationDefinitions.id, input.after.id), + ), + ) + : undefined; return executor.select().from(appAutomationDefinitions) - .where(where) + .where(and(scope, cursor)) .orderBy(desc(appAutomationDefinitions.created_at), desc(appAutomationDefinitions.id)) .limit(input.limit); } diff --git a/apps/api/src/lib/app-run-live-authorization.ts b/apps/api/src/lib/app-run-live-authorization.ts index cc22b845..7bbe20cb 100644 --- a/apps/api/src/lib/app-run-live-authorization.ts +++ b/apps/api/src/lib/app-run-live-authorization.ts @@ -192,12 +192,20 @@ function actorIdentity(actor: AppRunActor): string { } } -function runActor(type: AppRunSafeView['execution_actor_type'], id: string): AppRunActor { +function runActor( + type: AppRunSafeView['execution_actor_type'], + id: string, + automationUserId?: string, +): AppRunActor { switch (type) { case 'human': return { actor_type: 'human', user_id: id }; case 'agent_employee': return { actor_type: 'agent_employee', agent_employee_id: id }; case 'system': return { actor_type: 'system', system_id: id }; - case 'automation': return { actor_type: 'automation', automation_id: id }; + case 'automation': return { + actor_type: 'automation', + automation_id: id, + ...(automationUserId ? { user_id: automationUserId } : {}), + }; } } @@ -510,7 +518,13 @@ export class PostgresAppRunLiveAuthorization implements AppRunExecutionAuthorize const current = await this.#capture(tx, { org_id: run.org_id, authenticated_subject: stored.authenticated_subject, - execution_actor: runActor(run.execution_actor_type, run.execution_actor_id), + execution_actor: runActor( + run.execution_actor_type, + run.execution_actor_id, + stored.authenticated_subject.actor_type === 'human' + ? stored.authenticated_subject.user_id + : undefined, + ), provider_instance_id: run.provider_instance_id, provider_snapshot_id: internal.provider_snapshot_id, operation_name: run.operation_name, @@ -586,7 +600,13 @@ export class PostgresAppRunLiveAuthorization implements AppRunExecutionAuthorize return this.#captureAppVector(tx, { org_id: run.org_id, initiating_actor: runActor(run.initiating_actor_type, run.initiating_actor_id), - execution_actor: runActor(run.execution_actor_type, run.execution_actor_id), + execution_actor: runActor( + run.execution_actor_type, + run.execution_actor_id, + baseAuthorization.authenticated_subject.actor_type === 'human' + ? baseAuthorization.authenticated_subject.user_id + : undefined, + ), installation_id: internal.origin_app_installation_id, app_version_id: internal.origin_app_version_id, binding_key: internal.origin_app_binding_key, diff --git a/apps/api/src/lib/native-create.ts b/apps/api/src/lib/native-create.ts new file mode 100644 index 00000000..0097e4ad --- /dev/null +++ b/apps/api/src/lib/native-create.ts @@ -0,0 +1,50 @@ +import { createHash } from 'node:crypto'; +import { eq, sql } from 'drizzle-orm'; +import { z } from 'zod'; +import { nativeCreateRequests } from '@deft/db/schema'; +import { db } from './db.js'; + +type Transaction = Parameters[0]>[0]; +const keySchema = z.string().min(1).max(128).regex(/^[A-Za-z0-9._:-]+$/); + +export class NativeCreateError extends Error { + constructor(message: string, public code: string, public status: 400 | 404 | 409) { super(message); } +} + +export function nativeCreateKey(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const parsed = keySchema.safeParse(value); + if (!parsed.success) throw new NativeCreateError('Invalid Idempotency-Key', 'VALIDATION_ERROR', 400); + return parsed.data; +} + +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (value && typeof value === 'object') return `{${Object.entries(value).filter(([, v]) => v !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`; + return JSON.stringify(value) ?? 'null'; +} +const digest = (value: unknown) => createHash('sha256').update(canonical(value)).digest('hex'); + +/** Call only after current authorization and validation. Identity and create commit together. */ +export async function nativeCreate(options: { + orgId: string; userId: string; operation: string; key?: string; payload: unknown; + create: (tx: Transaction) => Promise; + replay: (tx: Transaction, id: string) => Promise; +}): Promise<{ value: T; replayed: boolean }> { + return db.transaction(async tx => { + if (!options.key) return { value: await options.create(tx), replayed: false }; + const identity = digest([options.orgId, options.userId, options.operation, options.key]); + const requestHash = digest(options.payload); + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`native-create:${identity}`}, 0))`); + const [prior] = await tx.select().from(nativeCreateRequests).where(eq(nativeCreateRequests.id, identity)).limit(1); + if (prior) { + if (prior.request_hash !== requestHash) throw new NativeCreateError('This create request was already used with different input', 'IDEMPOTENCY_CONFLICT', 409); + const value = await options.replay(tx, prior.resource_id); + if (!value) throw new NativeCreateError('The previously created resource is no longer available', 'NOT_FOUND', 404); + return { value, replayed: true }; + } + const value = await options.create(tx); + await tx.insert(nativeCreateRequests).values({ id: identity, org_id: options.orgId, user_id: options.userId, operation: options.operation, request_hash: requestHash, resource_id: value.id }); + return { value, replayed: false }; + }); +} diff --git a/apps/api/src/lib/queues.ts b/apps/api/src/lib/queues.ts index 45194fa1..dfdf2223 100644 --- a/apps/api/src/lib/queues.ts +++ b/apps/api/src/lib/queues.ts @@ -39,6 +39,9 @@ export type DequeuedJob = { export type DequeueOptions = { lockedBy?: string; leaseMs?: number; + orgId?: string; + jobName?: string; + dataMatch?: Readonly<{ key: string; value: string }>; }; export type FailJobOptions = { @@ -173,6 +176,10 @@ export async function dequeueJob( const lockedBy = opts?.lockedBy?.trim() || DEFAULT_WORKER_ID; const lockToken = crypto.randomUUID(); const leaseMs = positiveInteger(opts?.leaseMs, DEFAULT_LEASE_MS); + const orgId = opts?.orgId; + const jobName = opts?.jobName; + const dataKey = opts?.dataMatch?.key; + const dataValue = opts?.dataMatch?.value; const result = await db.execute(sql` UPDATE job_queue SET status = 'running', @@ -188,6 +195,9 @@ export async function dequeueJob( WHERE status = 'pending' AND queue = ${queueName} AND run_at <= now() + AND (${orgId ?? null}::text IS NULL OR org_id = ${orgId ?? null}) + AND (${jobName ?? null}::text IS NULL OR name = ${jobName ?? null}) + AND (${dataKey ?? null}::text IS NULL OR data->>${dataKey ?? null} = ${dataValue ?? null}) ORDER BY CASE WHEN queue = ${QUEUE_NAMES.AGENT_JOBS} AND name IN ('agent-reply', 'agent-employee-message', 'agent-employee-task') THEN 0 diff --git a/apps/api/src/lib/web-sessions.ts b/apps/api/src/lib/web-sessions.ts new file mode 100644 index 00000000..65c51a4c --- /dev/null +++ b/apps/api/src/lib/web-sessions.ts @@ -0,0 +1,115 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import jwt from 'jsonwebtoken'; +import { z } from 'zod'; +import { and, eq, isNull } from 'drizzle-orm'; +import { webSessions, users, revokedTokens, orgMembers } from '@deft/db/schema'; +import { db } from './db.js'; +import { env } from './env.js'; +import { OrgMembershipError, requireActiveOrgMembership } from './org-membership.js'; + +const claimsSchema = z.object({ + id: z.string().min(1).max(256), email: z.string(), org_id: z.string().min(1).max(256), + sid: z.string().uuid(), jti: z.string().uuid(), + purpose: z.enum(['web-access', 'web-refresh']), exp: z.number(), +}); +type Identity = { id: string; email: string; org_id: string }; +const hash = (token: string) => createHash('sha256').update(token).digest('hex'); +export const webSessionEvents = new EventEmitter(); +export class WebCredentialsChangedError extends Error {} + +export function emitWebSessionRevocations(sessionIds: readonly string[]) { + for (const sessionId of sessionIds) webSessionEvents.emit('revoked', sessionId); +} + +function claims(token: string, purpose: 'web-access' | 'web-refresh') { + const value = claimsSchema.parse(jwt.verify(token, purpose === 'web-access' ? env.JWT_SECRET : env.JWT_REFRESH_SECRET, { algorithms: ['HS256'] })); + if (value.purpose !== purpose) throw new Error('Invalid token purpose'); + return value; +} + +function tokens(identity: Identity, sid: string, expiresAt: Date) { + const remaining = Math.floor((expiresAt.getTime() - Date.now()) / 1000); + if (remaining <= 0) throw new Error('Session expired'); + return { + accessToken: jwt.sign({ ...identity, sid, jti: randomUUID(), purpose: 'web-access' }, env.JWT_SECRET, { algorithm: 'HS256', expiresIn: Math.min(900, remaining) }), + refreshToken: jwt.sign({ ...identity, sid, jti: randomUUID(), purpose: 'web-refresh' }, env.JWT_REFRESH_SECRET, { algorithm: 'HS256', expiresIn: remaining }), + }; +} + +export async function createWebSession(identity: Identity, expectedPasswordHash?: string) { + const sid = randomUUID(); + const expiresAt = new Date(Date.now() + 30 * 86400_000); + const pair = tokens(identity, sid, expiresAt); + await db.transaction(async (tx) => { + // Serialize issuance with password reset/change so an old-password login + // cannot create a family after that change has revoked earlier families. + const [user] = await tx.select().from(users).where(eq(users.id, identity.id)).for('share'); + if (!user || (expectedPasswordHash !== undefined && user.password_hash !== expectedPasswordHash)) throw new WebCredentialsChangedError('Credentials changed; sign in again'); + const [membership] = await tx.select({ id: orgMembers.id }).from(orgMembers).where(and(eq(orgMembers.org_id, identity.org_id), eq(orgMembers.user_id, identity.id), eq(orgMembers.is_active, true))).for('share'); + if (!membership) throw new OrgMembershipError('User is not an active member of this organization'); + await tx.insert(webSessions).values({ id: sid, user_id: identity.id, org_id: identity.org_id, refresh_token_hash: hash(pair.refreshToken), expires_at: expiresAt }); + }); + return pair; +} + +export async function verifyWebAccess(token: string) { + const payload = claims(token, 'web-access'); + const [session] = await db.select().from(webSessions).where(and(eq(webSessions.id, payload.sid), eq(webSessions.user_id, payload.id), eq(webSessions.org_id, payload.org_id))).limit(1); + if (!session || session.revoked_at || session.expires_at.getTime() <= Date.now()) throw new Error('Session unavailable'); + const membership = await requireActiveOrgMembership(payload.org_id, payload.id); + return { ...payload, role: membership.role }; +} + +export async function rotateWebSession(token: string) { + const payload = claims(token, 'web-refresh'); + const result = await db.transaction(async (tx) => { + const [session] = await tx.select().from(webSessions).where(and(eq(webSessions.id, payload.sid), eq(webSessions.user_id, payload.id), eq(webSessions.org_id, payload.org_id))).for('update'); + if (!session || session.revoked_at || session.expires_at.getTime() <= Date.now()) return null; + if (session.refresh_token_hash !== hash(token)) { + // Return rather than throw so revocation commits before the denial. + await tx.update(webSessions).set({ revoked_at: new Date() }).where(eq(webSessions.id, session.id)); + return null; + } + await requireActiveOrgMembership(payload.org_id, payload.id); + const pair = tokens({ id: payload.id, email: payload.email, org_id: payload.org_id }, session.id, session.expires_at); + await tx.update(webSessions).set({ refresh_token_hash: hash(pair.refreshToken) }).where(eq(webSessions.id, session.id)); + return pair; + }); + if (!result) { + webSessionEvents.emit('revoked', payload.sid); + throw new Error('Session revoked or refresh reused'); + } + return result; +} + +export async function revokeWebSession(token: string) { + const payload = claims(token, 'web-refresh'); + await db.update(webSessions).set({ revoked_at: new Date() }).where(and(eq(webSessions.id, payload.sid), eq(webSessions.user_id, payload.id), eq(webSessions.org_id, payload.org_id), isNull(webSessions.revoked_at))); + webSessionEvents.emit('revoked', payload.sid); +} + +type PasswordChangeAuthority = { expectedPasswordHash: string } | { resetToken: string; passwordVersion: number; orgId: string }; +export async function changeWebPassword(userId: string, passwordHash: string, authority: PasswordChangeAuthority) { + const sessions = await db.transaction(async (tx) => { + const [user] = await tx.select().from(users).where(eq(users.id, userId)).for('update'); + if (!user) throw new WebCredentialsChangedError('Credentials changed'); + if ('resetToken' in authority) { + if (user.password_version !== authority.passwordVersion) throw new WebCredentialsChangedError('Reset link superseded'); + const [membership] = await tx.select({ id: orgMembers.id }).from(orgMembers).where(and(eq(orgMembers.org_id, authority.orgId), eq(orgMembers.user_id, userId), eq(orgMembers.is_active, true))).for('share'); + if (!membership) throw new OrgMembershipError('Reset membership is no longer active'); + const inserted = await tx.insert(revokedTokens).values({ token_hash: hash(authority.resetToken), user_id: userId }).onConflictDoNothing().returning({ id: revokedTokens.id }); + if (!inserted.length) throw new Error('Reset token already used'); + } else if (user.password_hash !== authority.expectedPasswordHash) { + throw new WebCredentialsChangedError('Credentials changed'); + } + await tx.update(users).set({ password_hash: passwordHash, password_version: user.password_version + 1 }).where(eq(users.id, userId)); + return tx.update(webSessions).set({ revoked_at: new Date() }).where(and(eq(webSessions.user_id, userId), isNull(webSessions.revoked_at))).returning({ id: webSessions.id }); + }); + emitWebSessionRevocations(sessions.map(session => session.id)); +} + +export async function revokeMemberWebSessions(orgId: string, userId: string) { + const sessions = await db.update(webSessions).set({ revoked_at: new Date() }).where(and(eq(webSessions.org_id, orgId), eq(webSessions.user_id, userId), isNull(webSessions.revoked_at))).returning({ id: webSessions.id }); + emitWebSessionRevocations(sessions.map(session => session.id)); +} diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 72af3d87..371977b2 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -1,7 +1,6 @@ import { createMiddleware } from 'hono/factory'; -import jwt from 'jsonwebtoken'; -import { env } from '../lib/env.js'; -import { OrgMembershipError, requireActiveOrgMembership, type OrgRole } from '../lib/org-membership.js'; +import { verifyWebAccess } from '../lib/web-sessions.js'; +import { OrgMembershipError, type OrgRole } from '../lib/org-membership.js'; export type AuthUser = { id: string; @@ -19,7 +18,7 @@ declare module 'hono' { export const authMiddleware = createMiddleware(async (c, next) => { // Skip auth for auth routes (they're mounted before this middleware) const path = c.req.path; - if (path.startsWith('/api/auth') || path.includes('/callback')) { + if (path.startsWith('/api/auth/')) { return next(); } @@ -30,9 +29,8 @@ export const authMiddleware = createMiddleware(async (c, next) => { const token = authHeader.slice(7); try { - const payload = jwt.verify(token, env.JWT_SECRET) as AuthUser; - const membership = await requireActiveOrgMembership(payload.org_id, payload.id); - c.set('user', { ...payload, role: membership.role }); + const payload = await verifyWebAccess(token); + c.set('user', payload); return next(); } catch (err) { if (err instanceof OrgMembershipError) { diff --git a/apps/api/src/middleware/rate-limit.ts b/apps/api/src/middleware/rate-limit.ts index f65305bc..ab1e53a8 100644 --- a/apps/api/src/middleware/rate-limit.ts +++ b/apps/api/src/middleware/rate-limit.ts @@ -49,6 +49,10 @@ const WEBHOOK_LIMIT_PER_MINUTE = positiveIntFromEnv( 'DEFT_WEBHOOK_RATE_LIMIT_PER_MINUTE', isProduction ? 60 : 600, ); +const OAUTH_PUBLIC_LIMIT_PER_MINUTE = positiveIntFromEnv( + 'DEFT_OAUTH_PUBLIC_RATE_LIMIT_PER_MINUTE', + isProduction ? 120 : 1200, +); function userOrIpKey(c: Context): string { const user = c.get('user') as { id?: string } | undefined; @@ -202,3 +206,27 @@ export const webhookLimiter = withAuditBypass(rateLimiter({ code: 'WEBHOOK_RATE_LIMITED', }, 429), })); + +const oauthLimitResponse = (c: Context) => c.json({ + error: 'temporarily_unavailable', + error_description: 'OAuth request rate limit exceeded. Retry shortly.', +}, 429); + +// DCR and token endpoints are intentionally public. Keep both a per-source +// budget and a process-wide ceiling: forwarded IP headers are deployment input +// and must not let an attacker create an unbounded number of limiter buckets. +export const oauthPublicGlobalLimiter = withAuditBypass(rateLimiter({ + windowMs: 60 * 1000, + limit: OAUTH_PUBLIC_LIMIT_PER_MINUTE, + standardHeaders: 'draft-7', + keyGenerator: () => 'oauth-public-global', + handler: oauthLimitResponse, +})); + +export const oauthPublicIpLimiter = withAuditBypass(rateLimiter({ + windowMs: 60 * 1000, + limit: OAUTH_PUBLIC_LIMIT_PER_MINUTE, + standardHeaders: 'draft-7', + keyGenerator: ipKey, + handler: oauthLimitResponse, +})); diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index fc1c41f4..18f4af8d 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -1546,7 +1546,8 @@ agentRoutes.post('/actions/:id/approve', async (c) => { decision: 'approved', }); if ( - action.source === 'defty_capture' + action.action === 'create_task' + || action.source === 'defty_capture' || isModuleTaskLinkWriteAction(action.action) || isModuleWriteActionName(action.action) || isModuleRecordBulkCreateAction(action.action) diff --git a/apps/api/src/routes/apps.ts b/apps/api/src/routes/apps.ts index e759163d..6f3ff2c2 100644 --- a/apps/api/src/routes/apps.ts +++ b/apps/api/src/routes/apps.ts @@ -267,10 +267,17 @@ appRoutes.get('/operations', async (c) => { appRoutes.get('/:installationId/automations', async (c) => { try { + const cursor = c.req.query('cursor'); + const rawLimit = c.req.query('limit'); + const limit = rawLimit === undefined ? undefined : Number(rawLimit); + if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 50)) { + return c.json({ error: 'Invalid App automation page limit', code: 'VALIDATION_ERROR' }, 400); + } return c.json({ automations: await listManagedAppAutomations( managerFromContext(c), IdSchema.parse(c.req.param('installationId')), + { cursor, limit }, ), }); } catch (error) { @@ -390,7 +397,7 @@ appRoutes.post('/:installationId/enable', async (c) => { appRoutes.post('/:installationId/uninstall', async (c) => { try { - const body = disableSchema.parse(await c.req.json()); + const body = disableSchema.parse(await c.req.json().catch(() => undefined)); await refuseAppUninstall( managerFromContext(c), IdSchema.parse(c.req.param('installationId')), diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 92fe78d7..be5da8fc 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; import { z } from 'zod'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; @@ -13,26 +14,27 @@ import { spaces, spaceMembers, onboardingState, - revokedTokens, type UserNotificationPreferences, } from '@deft/db/schema'; import { env } from '../lib/env.js'; import { countOrgs, SINGLE_ORG_ERROR } from '../lib/single-org-guard.js'; import { ensureDeftyMembership, ensureDeftyDm } from '../lib/ensure-defty-membership.js'; import { OrgMembershipError, requireActiveOrgMembership } from '../lib/org-membership.js'; +import { createWebSession, rotateWebSession, revokeWebSession, changeWebPassword, verifyWebAccess, WebCredentialsChangedError } from '../lib/web-sessions.js'; export const authRoutes = new Hono(); +authRoutes.use('*', bodyLimit({ maxSize: 64 * 1024, onError: (c) => c.json({ error: 'Request body is too large', code: 'VALIDATION_ERROR' }, 413) })); const signupSchema = z.object({ - name: z.string().min(1), - email: z.string().email(), - password: z.string().min(8), - org_name: z.string().min(1), + name: z.string().min(1).max(160), + email: z.string().email().max(320), + password: z.string().min(8).max(1024), + org_name: z.string().min(1).max(160), }); const loginSchema = z.object({ - email: z.string().email(), - password: z.string().min(1), + email: z.string().email().max(320), + password: z.string().min(1).max(1024), }); const loginAttempts = new Map(); @@ -46,6 +48,7 @@ export function allowAccountLoginAttempt(email: string): boolean { const key = createHash('sha256').update(email.trim().toLowerCase()).digest('hex'); const current = loginAttempts.get(key); if (!current || current.resetAt <= now) { + if (!current && loginAttempts.size >= 10_000) return false; loginAttempts.set(key, { count: 1, resetAt: now + 60_000 }); return true; } @@ -53,20 +56,6 @@ export function allowAccountLoginAttempt(email: string): boolean { return current.count <= 10; } -function generateTokens(user: { id: string; email: string; org_id: string }) { - const accessToken = jwt.sign( - { id: user.id, email: user.email, org_id: user.org_id }, - env.JWT_SECRET, - { expiresIn: '15m' } - ); - const refreshToken = jwt.sign( - { id: user.id, email: user.email, org_id: user.org_id }, - env.JWT_REFRESH_SECRET, - { expiresIn: '30d' } - ); - return { accessToken, refreshToken }; -} - // GET /api/auth/has-workspace — public pre-check for the signup page authRoutes.get('/has-workspace', async (c) => { const { countOrgs } = await import('../lib/single-org-guard.js'); @@ -75,7 +64,7 @@ authRoutes.get('/has-workspace', async (c) => { // POST /api/auth/signup authRoutes.post('/signup', async (c) => { - const body = await c.req.json(); + const body = await c.req.json().catch(() => null); const parsed = signupSchema.safeParse(body); if (!parsed.success) { return c.json({ error: 'Invalid input', code: 'VALIDATION_ERROR' }, 400); @@ -160,7 +149,7 @@ authRoutes.post('/signup', async (c) => { org_created: true, }); - const tokens = generateTokens({ id: user!.id, email: user!.email!, org_id: org!.id }); + const tokens = await createWebSession({ id: user!.id, email: user!.email!, org_id: org!.id }); return c.json({ user: { id: user!.id, name: user!.name, email: user!.email }, @@ -171,7 +160,7 @@ authRoutes.post('/signup', async (c) => { // POST /api/auth/login authRoutes.post('/login', async (c) => { - const body = await c.req.json(); + const body = await c.req.json().catch(() => null); const parsed = loginSchema.safeParse(body); if (!parsed.success) { return c.json({ error: 'Invalid input', code: 'VALIDATION_ERROR' }, 400); @@ -212,7 +201,14 @@ authRoutes.post('/login', async (c) => { return c.json({ error: 'No active organization membership found', code: 'ORG_MEMBERSHIP_INACTIVE' }, 403); } - const tokens = generateTokens({ id: user.id, email: user.email!, org_id: membership.org_id }); + let tokens; + try { + tokens = await createWebSession({ id: user.id, email: user.email!, org_id: membership.org_id }, user.password_hash); + } catch (error) { + if (error instanceof WebCredentialsChangedError) return c.json({ error: 'Credentials changed. Please sign in again.', code: 'INVALID_CREDENTIALS' }, 401); + if (error instanceof OrgMembershipError) return c.json({ error: error.message, code: error.code }, 403); + throw error; + } return c.json({ user: { id: user.id, name: user.name, email: user.email }, @@ -223,24 +219,11 @@ authRoutes.post('/login', async (c) => { // POST /api/auth/refresh authRoutes.post('/refresh', async (c) => { - const body = await c.req.json(); - const { refreshToken } = body; - - if (!refreshToken) { - return c.json({ error: 'No refresh token', code: 'NO_TOKEN' }, 401); - } - - // Check revocation list before validating the JWT - const tokenHash = createHash('sha256').update(refreshToken).digest('hex'); - const [revoked] = await db.select().from(revokedTokens).where(eq(revokedTokens.token_hash, tokenHash)).limit(1); - if (revoked) { - return c.json({ error: 'Token revoked', code: 'TOKEN_REVOKED' }, 401); - } + const parsed = z.object({ refreshToken: z.string().min(1).max(8192) }).safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: 'Invalid input', code: 'VALIDATION_ERROR' }, 400); try { - const payload = jwt.verify(refreshToken, env.JWT_REFRESH_SECRET) as { id: string; email: string; org_id: string }; - await requireActiveOrgMembership(payload.org_id, payload.id); - const tokens = generateTokens({ id: payload.id, email: payload.email, org_id: payload.org_id }); + const tokens = await rotateWebSession(parsed.data.refreshToken); return c.json(tokens); } catch (err) { if (err instanceof OrgMembershipError) { @@ -250,23 +233,17 @@ authRoutes.post('/refresh', async (c) => { } }); -// POST /api/auth/logout — revoke the caller's refresh token +// POST /api/auth/logout — revoke the entire caller session, including access. authRoutes.post('/logout', async (c) => { + const parsed = z.object({ refreshToken: z.string().min(1).max(8192).optional() }).safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: 'Invalid input', code: 'VALIDATION_ERROR' }, 400); try { - const body = await c.req.json().catch(() => ({} as { refreshToken?: string })); - const token = body.refreshToken; + const token = parsed.data.refreshToken; if (!token) return c.json({ ok: true }); // idempotent: nothing to revoke - - const tokenHash = createHash('sha256').update(token).digest('hex'); - await db.insert(revokedTokens).values({ - id: crypto.randomUUID(), - token_hash: tokenHash, - }).onConflictDoNothing(); - + await revokeWebSession(token); return c.json({ ok: true }); } catch (err) { - console.error('[auth] Failed to logout:', err); - return c.json({ error: 'Failed to logout', code: 'INTERNAL_ERROR' }, 500); + return c.json({ error: 'Invalid token', code: 'INVALID_TOKEN' }, 401); } }); @@ -279,7 +256,7 @@ authRoutes.get('/me', async (c) => { const token = authHeader.slice(7); try { - const payload = jwt.verify(token, env.JWT_SECRET) as { id: string; email: string; org_id: string }; + const payload = await verifyWebAccess(token); const [user] = await db.select({ id: users.id, @@ -413,12 +390,12 @@ authRoutes.post('/forgot-password', async (c) => { // POST /api/auth/reset-password — reset password with token const resetPasswordSchema = z.object({ - token: z.string().min(1), - password: z.string().min(8), + token: z.string().min(1).max(8192), + password: z.string().min(8).max(128), }); authRoutes.post('/reset-password', async (c) => { - const body = await c.req.json(); + const body = await c.req.json().catch(() => null); const parsed = resetPasswordSchema.safeParse(body); if (!parsed.success) { return c.json({ error: 'Invalid input', code: 'VALIDATION_ERROR' }, 400); @@ -427,13 +404,10 @@ authRoutes.post('/reset-password', async (c) => { const { token, password } = parsed.data; try { - const payload = jwt.verify(token, env.JWT_SECRET) as { id: string; email: string; purpose?: string }; - if (payload.purpose !== 'password-reset') { - return c.json({ error: 'Invalid reset token', code: 'INVALID_TOKEN' }, 400); - } + const payload = z.object({ id: z.string().min(1), org_id: z.string().min(1), purpose: z.literal('password-reset'), password_version: z.number().int().nonnegative() }).parse(jwt.verify(token, env.JWT_SECRET, { algorithms: ['HS256'] })); const passwordHash = await bcrypt.hash(password, 12); - await db.update(users).set({ password_hash: passwordHash }).where(eq(users.id, payload.id)); + await changeWebPassword(payload.id, passwordHash, { resetToken: token, passwordVersion: payload.password_version, orgId: payload.org_id }); return c.json({ success: true, message: 'Password has been reset. You can now log in.' }); } catch { @@ -451,9 +425,9 @@ authRoutes.patch('/password', async (c) => { const token = authHeader.slice(7); try { - const payload = jwt.verify(token, env.JWT_SECRET) as { id: string; email: string; org_id: string }; + const payload = await verifyWebAccess(token); await requireActiveOrgMembership(payload.org_id, payload.id); - const body = await c.req.json(); + const body = await c.req.json().catch(() => null); const parsed = passwordChangeSchema.safeParse(body); if (!parsed.success) { return c.json({ error: 'Invalid input', code: 'VALIDATION_ERROR' }, 400); @@ -474,7 +448,7 @@ authRoutes.patch('/password', async (c) => { } const passwordHash = await bcrypt.hash(parsed.data.new_password, 12); - await db.update(users).set({ password_hash: passwordHash }).where(eq(users.id, payload.id)); + await changeWebPassword(payload.id, passwordHash, { expectedPasswordHash: user.password_hash }); return c.json({ success: true }); } catch (err) { @@ -492,7 +466,7 @@ authRoutes.get('/onboarding', async (c) => { } const token = authHeader.slice(7); try { - const payload = jwt.verify(token, env.JWT_SECRET) as { id: string; org_id: string }; + const payload = await verifyWebAccess(token); await requireActiveOrgMembership(payload.org_id, payload.id); let [state] = await db.select().from(onboardingState).where(eq(onboardingState.user_id, payload.id)).limit(1); if (!state) { @@ -526,7 +500,7 @@ authRoutes.patch('/onboarding', async (c) => { } const token = authHeader.slice(7); try { - const payload = jwt.verify(token, env.JWT_SECRET) as { id: string; org_id: string }; + const payload = await verifyWebAccess(token); await requireActiveOrgMembership(payload.org_id, payload.id); const body = await c.req.json().catch(() => ({})); const parsed = onboardingUpdateSchema.safeParse(body); @@ -560,9 +534,9 @@ authRoutes.patch('/me', async (c) => { const token = authHeader.slice(7); try { - const payload = jwt.verify(token, env.JWT_SECRET) as { id: string; email: string; org_id: string }; + const payload = await verifyWebAccess(token); const membership = await requireActiveOrgMembership(payload.org_id, payload.id); - const body = await c.req.json(); + const body = await c.req.json().catch(() => null); const parsed = profileUpdateSchema.safeParse(body); if (!parsed.success) { return c.json({ error: 'Invalid input', code: 'VALIDATION_ERROR' }, 400); diff --git a/apps/api/src/routes/connections.ts b/apps/api/src/routes/connections.ts index d703ee21..9dcb53b8 100644 --- a/apps/api/src/routes/connections.ts +++ b/apps/api/src/routes/connections.ts @@ -2,30 +2,9 @@ import { Hono } from 'hono'; import { eq, and } from 'drizzle-orm'; import { db } from '../lib/db.js'; import { connectedAccounts, events } from '@deft/db/schema'; -import { env } from '../lib/env.js'; -import { encrypt, decrypt } from '../lib/encryption.js'; export const connectionRoutes = new Hono(); -// Provider configs -const PROVIDERS: Record string; - clientSecret: () => string; -}> = { - github: { - name: 'GitHub', - authUrl: 'https://github.com/login/oauth/authorize', - tokenUrl: 'https://github.com/login/oauth/access_token', - scopes: ['repo', 'read:org', 'read:user'], - clientId: () => env.GITHUB_CLIENT_ID, - clientSecret: () => env.GITHUB_CLIENT_SECRET, - }, -}; - // GET /api/connections — list user's connections connectionRoutes.get('/', async (c) => { const user = c.get('user'); @@ -56,135 +35,12 @@ connectionRoutes.get('/', async (c) => { // POST /api/connections/:provider/connect — initiate OAuth flow connectionRoutes.post('/:provider/connect', async (c) => { - const user = c.get('user'); - const provider = c.req.param('provider'); - const config = PROVIDERS[provider]; - - if (!config) { - return c.json({ error: 'Unknown provider', code: 'UNKNOWN_PROVIDER' }, 400); - } - - if (!config.clientId()) { - return c.json({ error: `${config.name} is not configured. Add credentials to .env`, code: 'NOT_CONFIGURED' }, 503); - } - - const redirectUri = `${env.NEXT_PUBLIC_APP_URL}/api/connections/${provider}/callback`; - const state = Buffer.from(JSON.stringify({ user_id: user.id, org_id: user.org_id })).toString('base64url'); - - const params = new URLSearchParams({ - client_id: config.clientId(), - redirect_uri: redirectUri, - scope: config.scopes.join(' '), - state, - response_type: 'code', - }); - - return c.json({ url: `${config.authUrl}?${params.toString()}` }); + return c.json({ error: 'Native provider linking is not supported', code: 'NOT_FOUND' }, 404); }); // GET /api/connections/:provider/callback — OAuth callback handler connectionRoutes.get('/:provider/callback', async (c) => { - const provider = c.req.param('provider'); - const config = PROVIDERS[provider]; - if (!config) return c.json({ error: 'Unknown provider' }, 400); - - const code = c.req.query('code'); - const state = c.req.query('state'); - if (!code || !state) return c.json({ error: 'Missing code or state' }, 400); - - // Decode state - let stateData: { user_id: string; org_id: string }; - try { - stateData = JSON.parse(Buffer.from(state, 'base64url').toString()); - } catch { - return c.json({ error: 'Invalid state' }, 400); - } - - const redirectUri = `${env.NEXT_PUBLIC_APP_URL}/api/connections/${provider}/callback`; - - // Exchange code for tokens - try { - const tokenRes = await fetch(config.tokenUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, - body: new URLSearchParams({ - client_id: config.clientId(), - client_secret: config.clientSecret(), - code, - redirect_uri: redirectUri, - grant_type: 'authorization_code', - }), - }); - - const tokenData = await tokenRes.json() as Record; - if (!tokenData.access_token) { - return c.json({ error: 'Failed to get access token', details: tokenData }, 400); - } - - // Encrypt tokens - const accessTokenEncrypted = encrypt(tokenData.access_token); - const refreshTokenEncrypted = tokenData.refresh_token ? encrypt(tokenData.refresh_token) : null; - const expiresAt = tokenData.expires_in - ? new Date(Date.now() + tokenData.expires_in * 1000) - : null; - - // Get provider account ID - let providerAccountId: string | null = null; - if (provider === 'github') { - const userRes = await fetch('https://api.github.com/user', { - headers: { Authorization: `token ${tokenData.access_token}`, Accept: 'application/json' }, - }); - const ghUser = await userRes.json() as Record; - providerAccountId = String(ghUser.id); - } - - // Upsert connection - const existing = await db.select({ id: connectedAccounts.id }) - .from(connectedAccounts) - .where(and(eq(connectedAccounts.user_id, stateData.user_id), eq(connectedAccounts.provider, provider))) - .limit(1); - - if (existing.length > 0) { - await db.update(connectedAccounts) - .set({ - access_token_encrypted: accessTokenEncrypted, - refresh_token_encrypted: refreshTokenEncrypted, - token_expires_at: expiresAt, - scopes: config.scopes.join(' '), - provider_account_id: providerAccountId, - sync_error: null, - }) - .where(eq(connectedAccounts.id, existing[0]!.id)); - } else { - await db.insert(connectedAccounts).values({ - org_id: stateData.org_id, - user_id: stateData.user_id, - provider, - provider_account_id: providerAccountId, - access_token_encrypted: accessTokenEncrypted, - refresh_token_encrypted: refreshTokenEncrypted, - token_expires_at: expiresAt, - scopes: config.scopes.join(' '), - }); - } - - // Trigger immediate sync - if (provider === 'github') { - const [conn] = await db.select({ id: connectedAccounts.id }) - .from(connectedAccounts) - .where(and(eq(connectedAccounts.user_id, stateData.user_id), eq(connectedAccounts.provider, provider))) - .limit(1); - if (conn) { - import('../workers/github-sync.js').then(m => m.syncGitHubForUser(conn.id)).catch(console.error); - } - } - - // Redirect back to settings - return c.redirect(`${env.NEXT_PUBLIC_APP_URL}/settings/integrations?connected=${provider}`); - } catch (err) { - console.error(`OAuth callback error for ${provider}:`, err); - return c.redirect(`${env.NEXT_PUBLIC_APP_URL}/settings/integrations?error=${provider}`); - } + return c.json({ error: 'Native provider linking is not supported', code: 'NOT_FOUND' }, 404); }); // POST /api/connections/:provider/sync — trigger manual sync @@ -193,7 +49,11 @@ connectionRoutes.post('/:provider/sync', async (c) => { const provider = c.req.param('provider'); const [conn] = await db.select({ id: connectedAccounts.id }) .from(connectedAccounts) - .where(and(eq(connectedAccounts.user_id, user.id), eq(connectedAccounts.provider, provider))) + .where(and( + eq(connectedAccounts.user_id, user.id), + eq(connectedAccounts.org_id, user.org_id), + eq(connectedAccounts.provider, provider), + )) .limit(1); if (!conn) return c.json({ error: 'Not connected' }, 404); @@ -213,7 +73,11 @@ connectionRoutes.delete('/:provider', async (c) => { // Delete synced events const [conn] = await db.select({ id: connectedAccounts.id }) .from(connectedAccounts) - .where(and(eq(connectedAccounts.user_id, user.id), eq(connectedAccounts.provider, provider))) + .where(and( + eq(connectedAccounts.user_id, user.id), + eq(connectedAccounts.org_id, user.org_id), + eq(connectedAccounts.provider, provider), + )) .limit(1); if (conn) { diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 2faeb842..e4c9c74e 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { eq, and } from 'drizzle-orm'; import { z } from 'zod'; import { db } from '../lib/db.js'; +import { nativeCreate, nativeCreateKey, NativeCreateError } from '../lib/native-create.js'; import { events } from '@deft/db/schema'; export const eventRoutes = new Hono(); @@ -26,6 +27,7 @@ export const createEventSchema = z.object({ // POST / — create a native calendar event eventRoutes.post('/', async (c) => { + try { const user = c.get('user'); const body = await c.req.json(); @@ -48,7 +50,12 @@ eventRoutes.post('/', async (c) => { return c.json({ error: 'end must be after start', code: 'INVALID_RANGE' }, 400); } - const [created] = await db.insert(events).values({ + const { value: created } = await nativeCreate({ + orgId: user.org_id, userId: user.id, operation: 'event', + key: nativeCreateKey(c.req.header('Idempotency-Key')), payload: parsed.data, + replay: async (tx, id) => (await tx.select().from(events).where(and(eq(events.id, id), eq(events.org_id, user.org_id), eq(events.user_id, user.id), eq(events.source, 'native'))).limit(1))[0], + create: async (tx) => { + const [created] = await tx.insert(events).values({ org_id: user.org_id, source: 'native' as const, event_type: 'calendar_event', @@ -74,7 +81,13 @@ eventRoutes.post('/', async (c) => { connected_account_id: null, }).returning(); + return created!; + } }); return c.json(created, 201); + } catch (err) { + if (err instanceof NativeCreateError) return c.json({ error: err.message, code: err.code }, err.status); + throw err; + } }); // PATCH /:id — update a native event diff --git a/apps/api/src/routes/invites.ts b/apps/api/src/routes/invites.ts index a09e215d..9a8297fe 100644 --- a/apps/api/src/routes/invites.ts +++ b/apps/api/src/routes/invites.ts @@ -7,6 +7,7 @@ import { db } from '../lib/db.js'; import { users, orgs, orgMembers, invites } from '@deft/db/schema'; import { env } from '../lib/env.js'; import { ensureDeftyMembership, ensureDeftyDm } from '../lib/ensure-defty-membership.js'; +import { createWebSession, WebCredentialsChangedError } from '../lib/web-sessions.js'; export const inviteRoutes = new Hono(); @@ -21,20 +22,6 @@ type InvitePayload = { exp?: number; }; -function generateAuthTokens(user: { id: string; email: string; org_id: string }) { - const accessToken = jwt.sign( - { id: user.id, email: user.email, org_id: user.org_id }, - env.JWT_SECRET, - { expiresIn: '15m' }, - ); - const refreshToken = jwt.sign( - { id: user.id, email: user.email, org_id: user.org_id }, - env.JWT_REFRESH_SECRET, - { expiresIn: '30d' }, - ); - return { accessToken, refreshToken }; -} - // GET /api/invites/preview/:token — public preview of an invite // Used by the accept page to render "Sara invited you to Acme". inviteRoutes.get('/preview/:token', async (c) => { @@ -127,45 +114,6 @@ inviteRoutes.post('/accept', async (c) => { return c.json({ error: 'invalid', code: 'INVITE_INVALID' }, 400); } - const [invite] = await db - .select({ - id: invites.id, - accepted_at: invites.accepted_at, - expires_at: invites.expires_at, - }) - .from(invites) - .where(and(eq(invites.token, parsed.data.token), eq(invites.org_id, payload.org_id))) - .limit(1); - - if (!invite) { - return c.json({ error: 'invalid', code: 'INVITE_INVALID' }, 400); - } - if (invite.accepted_at) { - return c.json({ error: 'already accepted', code: 'INVITE_ALREADY_ACCEPTED' }, 400); - } - if (invite.expires_at && invite.expires_at < new Date()) { - return c.json({ error: 'expired', code: 'INVITE_EXPIRED' }, 400); - } - - // Verify user + membership still exist (admin may have removed them) - const [user] = await db - .select() - .from(users) - .where(eq(users.id, payload.user_id)) - .limit(1); - if (!user) { - return c.json({ error: 'invalid', code: 'INVITE_INVALID' }, 400); - } - - const [membership] = await db - .select() - .from(orgMembers) - .where(and(eq(orgMembers.user_id, payload.user_id), eq(orgMembers.org_id, payload.org_id))) - .limit(1); - if (!membership || !membership.is_active) { - return c.json({ error: 'invalid', code: 'INVITE_REVOKED' }, 400); - } - const passwordHash = await bcrypt.hash(parsed.data.password, 12); const updates: Record = { password_hash: passwordHash, @@ -173,11 +121,50 @@ inviteRoutes.post('/accept', async (c) => { }; if (parsed.data.name) updates.name = parsed.data.name; - await db.update(users).set(updates).where(eq(users.id, payload.user_id)); - await db - .update(invites) - .set({ accepted_by: payload.user_id, accepted_at: new Date() }) - .where(eq(invites.id, invite.id)); + type AcceptanceFailure = 'INVITE_INVALID' | 'INVITE_ALREADY_ACCEPTED' | 'INVITE_EXPIRED' | 'INVITE_REVOKED'; + const accepted = await db.transaction(async (tx) => { + // Lock the invite first so exactly one concurrent acceptance can set the + // password and mint a session from this token. + const [invite] = await tx + .select({ id: invites.id, accepted_at: invites.accepted_at, expires_at: invites.expires_at }) + .from(invites) + .where(and(eq(invites.token, parsed.data.token), eq(invites.org_id, payload.org_id))) + .for('update'); + if (!invite) return { failure: 'INVITE_INVALID' as AcceptanceFailure }; + if (invite.accepted_at) return { failure: 'INVITE_ALREADY_ACCEPTED' as AcceptanceFailure }; + if (invite.expires_at && invite.expires_at < new Date()) return { failure: 'INVITE_EXPIRED' as AcceptanceFailure }; + + const [user] = await tx.select().from(users).where(eq(users.id, payload.user_id)).for('update'); + if (!user) return { failure: 'INVITE_INVALID' as AcceptanceFailure }; + // An invite-created account has no password until its one accepted invite + // establishes credentials. Never let a stale pending invite replace + // credentials established by another invite or an admin recovery. + if (user.password_hash) return { failure: 'INVITE_ALREADY_ACCEPTED' as AcceptanceFailure }; + const [membership] = await tx + .select({ id: orgMembers.id }) + .from(orgMembers) + .where(and(eq(orgMembers.user_id, payload.user_id), eq(orgMembers.org_id, payload.org_id), eq(orgMembers.is_active, true))) + .for('share'); + if (!membership) return { failure: 'INVITE_REVOKED' as AcceptanceFailure }; + + await tx.update(users).set({ ...updates, password_version: user.password_version + 1 }).where(eq(users.id, payload.user_id)); + await tx.update(invites) + .set({ accepted_by: payload.user_id, accepted_at: new Date() }) + .where(and(eq(invites.id, invite.id), eq(invites.org_id, payload.org_id))); + return { user }; + }); + + if ('failure' in accepted) { + const messages: Record = { + INVITE_INVALID: 'invalid', + INVITE_ALREADY_ACCEPTED: 'already accepted', + INVITE_EXPIRED: 'expired', + INVITE_REVOKED: 'invalid', + }; + const failure = accepted.failure as AcceptanceFailure; + return c.json({ error: messages[failure], code: failure }, 400); + } + const user = accepted.user; // Ensure Defty is in the org and materialize the 1:1 DM so the new // member sees it in their sidebar immediately. Both are idempotent; @@ -212,7 +199,15 @@ inviteRoutes.post('/accept', async (c) => { } })(); - const tokens = generateAuthTokens({ id: user.id, email: user.email!, org_id: payload.org_id }); + let tokens; + try { + tokens = await createWebSession({ id: user.id, email: user.email!, org_id: payload.org_id }, passwordHash); + } catch (error) { + if (error instanceof WebCredentialsChangedError) { + return c.json({ error: 'Credentials changed; sign in again', code: 'CREDENTIALS_CHANGED' }, 409); + } + throw error; + } return c.json({ user: { id: user.id, name: parsed.data.name ?? user.name, email: user.email }, diff --git a/apps/api/src/routes/members.ts b/apps/api/src/routes/members.ts index 0959567a..9641104e 100644 --- a/apps/api/src/routes/members.ts +++ b/apps/api/src/routes/members.ts @@ -25,11 +25,13 @@ import { userGroupMembers, userGroups, wikiPages, + webSessions, } from '@deft/db/schema'; import { env } from '../lib/env.js'; import { DEFTY_EMAIL } from '../lib/ensure-defty-membership.js'; import { OrgMembershipError, requireOrgAdminOrOwner } from '../lib/org-membership.js'; import { evictActiveHuddleParticipants } from '../socket.js'; +import { emitWebSessionRevocations } from '../lib/web-sessions.js'; const INVITE_TTL = '7d'; const RECOVERY_TTL = '24h'; @@ -109,12 +111,28 @@ async function getCurrentMembership(orgId: string, userId: string) { async function revokeMemberWorkspaceAccess(orgId: string, memberId: string, deactivateMembership = true) { const revokedAt = new Date(); - - if (deactivateMembership) { - await db.update(orgMembers) - .set({ is_active: false, updated_at: revokedAt }) - .where(and(eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, memberId))); - } + const revokedSessionIds = await db.transaction(async (tx) => { + const [user] = await tx.select({ password_version: users.password_version }) + .from(users).where(eq(users.id, memberId)).for('update'); + const [membership] = await tx.select({ id: orgMembers.id }) + .from(orgMembers) + .where(and(eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, memberId))) + .for('update'); + if (!user || !membership) return []; + if (deactivateMembership) { + await tx.update(users).set({ password_version: user.password_version + 1 }).where(eq(users.id, memberId)); + await tx.update(orgMembers).set({ is_active: false, updated_at: revokedAt }).where(eq(orgMembers.id, membership.id)); + } + const sessions = await tx.select({ id: webSessions.id }) + .from(webSessions) + .where(and(eq(webSessions.org_id, orgId), eq(webSessions.user_id, memberId), sql`${webSessions.revoked_at} IS NULL`)) + .for('update'); + if (sessions.length) { + await tx.update(webSessions).set({ revoked_at: revokedAt }).where(inArray(webSessions.id, sessions.map(session => session.id))); + } + return sessions.map(session => session.id); + }); + emitWebSessionRevocations(revokedSessionIds); await db.execute(sql` DELETE FROM ${spaceMembers} @@ -1192,69 +1210,47 @@ memberRoutes.post('/invites/:id/reissue', async (c) => { return adminForbidden(c, err); } - const [invite] = await db.select() - .from(invites) - .where(and(eq(invites.org_id, currentUser.org_id), eq(invites.id, inviteId))) - .limit(1); - - if (!invite) { - return c.json({ error: 'Invite not found', code: 'NOT_FOUND' }, 404); - } - if (invite.accepted_at) { - return c.json({ error: 'Accepted invites cannot be reissued', code: 'INVITE_ACCEPTED' }, 409); - } - - const claims = decodeInviteClaims(invite.token); - let userId = claims?.user_id ?? null; - let role = claims?.role as 'admin' | 'member' | 'guest' | undefined; - const email = invite.email ?? claims?.email; - - if ((!userId || !role) && email) { - const [target] = await db.select({ - id: users.id, - role: orgMembers.role, - }) - .from(users) - .innerJoin(orgMembers, eq(orgMembers.user_id, users.id)) - .where(and(eq(users.email, email), eq(orgMembers.org_id, currentUser.org_id))) - .limit(1); - userId = userId ?? target?.id ?? null; - role = role ?? (target?.role as 'admin' | 'member' | 'guest' | undefined); - } - - if (!userId || !email) { + const result = await db.transaction(async (tx) => { + const [invite] = await tx.select().from(invites) + .where(and(eq(invites.org_id, currentUser.org_id), eq(invites.id, inviteId))) + .for('update'); + if (!invite) return { failure: 'NOT_FOUND' as const }; + if (invite.accepted_at) return { failure: 'INVITE_ACCEPTED' as const }; + + const claims = decodeInviteClaims(invite.token); + let userId = claims?.user_id ?? null; + let role = claims?.role as 'admin' | 'member' | 'guest' | undefined; + const email = invite.email ?? claims?.email; + if ((!userId || !role) && email) { + const [target] = await tx.select({ id: users.id, role: orgMembers.role }) + .from(users).innerJoin(orgMembers, eq(orgMembers.user_id, users.id)) + .where(and(eq(users.email, email), eq(orgMembers.org_id, currentUser.org_id))) + .limit(1); + userId = userId ?? target?.id ?? null; + role = role ?? (target?.role as 'admin' | 'member' | 'guest' | undefined); + } + if (!userId || !email) return { failure: 'INVITE_INVALID' as const }; + + const inviteToken = jwt.sign({ + purpose: 'invite-accept', user_id: userId, org_id: currentUser.org_id, email, + inviter_id: currentUser.id, role: role ?? 'member', nonce: crypto.randomUUID(), + }, env.JWT_SECRET, { expiresIn: INVITE_TTL }); + const decoded = jwt.decode(inviteToken) as { exp?: number } | null; + const expiresAtDate = decoded?.exp ? new Date(decoded.exp * 1000) : null; + await tx.update(invites).set({ token: inviteToken, invited_by: currentUser.id, expires_at: expiresAtDate ?? undefined, updated_at: new Date() }) + .where(eq(invites.id, inviteId)); + return { inviteToken, expiresAtDate }; + }); + if ('failure' in result) { + if (result.failure === 'NOT_FOUND') return c.json({ error: 'Invite not found', code: 'NOT_FOUND' }, 404); + if (result.failure === 'INVITE_ACCEPTED') return c.json({ error: 'Accepted invites cannot be reissued', code: 'INVITE_ACCEPTED' }, 409); return c.json({ error: 'Invite payload is incomplete', code: 'INVITE_INVALID' }, 409); } - const inviteToken = jwt.sign( - { - purpose: 'invite-accept', - user_id: userId, - org_id: currentUser.org_id, - email, - inviter_id: currentUser.id, - role: role ?? 'member', - nonce: crypto.randomUUID(), - }, - env.JWT_SECRET, - { expiresIn: INVITE_TTL }, - ); - - const decoded = jwt.decode(inviteToken) as { exp?: number } | null; - const expiresAtDate = decoded?.exp ? new Date(decoded.exp * 1000) : null; - await db.update(invites) - .set({ - token: inviteToken, - invited_by: currentUser.id, - expires_at: expiresAtDate ?? undefined, - updated_at: new Date(), - }) - .where(eq(invites.id, inviteId)); - return c.json({ success: true, - invite_url: buildInviteUrl(inviteToken), - expires_at: expiresAtDate?.toISOString() ?? null, + invite_url: buildInviteUrl(result.inviteToken), + expires_at: result.expiresAtDate?.toISOString() ?? null, }); } catch (err) { console.error('Failed to reissue invite:', err); @@ -1274,47 +1270,56 @@ memberRoutes.delete('/invites/:id', async (c) => { return adminForbidden(c, err); } - const [invite] = await db.select() - .from(invites) - .where(and(eq(invites.org_id, currentUser.org_id), eq(invites.id, inviteId))) - .limit(1); + const revokedAt = new Date(); + const result = await db.transaction(async (tx) => { + const [invite] = await tx.select().from(invites) + .where(and(eq(invites.org_id, currentUser.org_id), eq(invites.id, inviteId))) + .for('update'); + if (!invite) return { failure: 'NOT_FOUND' as const }; + if (invite.accepted_at) return { failure: 'INVITE_ACCEPTED' as const }; + + const claims = decodeInviteClaims(invite.token); + let userId = claims?.user_id ?? null; + const email = invite.email ?? claims?.email; + if (!userId && email) { + const [found] = await tx.select({ id: users.id }).from(users) + .where(eq(users.email, email)).limit(1); + userId = found?.id ?? null; + } - if (!invite) { - return c.json({ error: 'Invite not found', code: 'NOT_FOUND' }, 404); - } - if (invite.accepted_at) { + const revokedSessionIds: string[] = []; + let deactivatedUserId: string | null = null; + if (userId) { + // Invite row is already locked; keep remaining order user -> membership -> sessions. + const [user] = await tx.select({ password_hash: users.password_hash, password_version: users.password_version }) + .from(users).where(eq(users.id, userId)).for('update'); + const [membership] = await tx.select({ id: orgMembers.id, role: orgMembers.role, is_active: orgMembers.is_active }) + .from(orgMembers) + .where(and(eq(orgMembers.org_id, currentUser.org_id), eq(orgMembers.user_id, userId))) + .for('update'); + if (user && membership?.is_active && !user.password_hash && membership.role !== 'owner') { + await tx.update(users).set({ password_version: user.password_version + 1 }).where(eq(users.id, userId)); + await tx.update(orgMembers).set({ is_active: false, updated_at: revokedAt }).where(eq(orgMembers.id, membership.id)); + const sessions = await tx.select({ id: webSessions.id }).from(webSessions) + .where(and(eq(webSessions.org_id, currentUser.org_id), eq(webSessions.user_id, userId), sql`${webSessions.revoked_at} IS NULL`)) + .for('update'); + if (sessions.length) { + await tx.update(webSessions).set({ revoked_at: revokedAt }).where(inArray(webSessions.id, sessions.map(session => session.id))); + revokedSessionIds.push(...sessions.map(session => session.id)); + } + deactivatedUserId = userId; + } + } + await tx.delete(invites).where(eq(invites.id, inviteId)); + return { revokedSessionIds, deactivatedUserId }; + }); + if ('failure' in result) { + if (result.failure === 'NOT_FOUND') return c.json({ error: 'Invite not found', code: 'NOT_FOUND' }, 404); return c.json({ error: 'Accepted invites cannot be revoked here', code: 'INVITE_ACCEPTED' }, 409); } - - const claims = decodeInviteClaims(invite.token); - let userId = claims?.user_id ?? null; - const email = invite.email ?? claims?.email; - - if (!userId && email) { - const [target] = await db.select({ id: users.id }) - .from(users) - .innerJoin(orgMembers, eq(orgMembers.user_id, users.id)) - .where(and(eq(users.email, email), eq(orgMembers.org_id, currentUser.org_id))) - .limit(1); - userId = target?.id ?? null; - } - - await db.delete(invites).where(eq(invites.id, inviteId)); - - if (userId) { - const [target] = await db.select({ - password_hash: users.password_hash, - role: orgMembers.role, - is_active: orgMembers.is_active, - }) - .from(users) - .innerJoin(orgMembers, eq(orgMembers.user_id, users.id)) - .where(and(eq(users.id, userId), eq(orgMembers.org_id, currentUser.org_id))) - .limit(1); - - if (target?.is_active && !target.password_hash && target.role !== 'owner') { - await revokeMemberWorkspaceAccess(currentUser.org_id, userId, true); - } + emitWebSessionRevocations(result.revokedSessionIds); + if (result.deactivatedUserId) { + await revokeMemberWorkspaceAccess(currentUser.org_id, result.deactivatedUserId, false); } return c.json({ success: true }); @@ -1336,7 +1341,7 @@ memberRoutes.post('/:id/recovery-url', async (c) => { } const [target] = await db - .select({ id: users.id, email: users.email }) + .select({ id: users.id, email: users.email, password_version: users.password_version }) .from(users) .innerJoin(orgMembers, eq(orgMembers.user_id, users.id)) .where(and(eq(orgMembers.org_id, currentUser.org_id), eq(users.id, memberId), eq(orgMembers.is_active, true))) @@ -1347,7 +1352,7 @@ memberRoutes.post('/:id/recovery-url', async (c) => { } const resetToken = jwt.sign( - { id: target.id, email: target.email, purpose: 'password-reset' }, + { id: target.id, email: target.email, org_id: currentUser.org_id, purpose: 'password-reset', password_version: target.password_version }, env.JWT_SECRET, { expiresIn: RECOVERY_TTL }, ); diff --git a/apps/api/src/routes/messages.ts b/apps/api/src/routes/messages.ts index f7262437..0ccd9ab7 100644 --- a/apps/api/src/routes/messages.ts +++ b/apps/api/src/routes/messages.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { z } from 'zod'; import { eq, and, desc, lt, lte, gt, sql, isNull, inArray } from 'drizzle-orm'; import { db } from '../lib/db.js'; +import { nativeCreate, nativeCreateKey, NativeCreateError } from '../lib/native-create.js'; import { messages, users, reactions, spaces, spaceMembers, orgs, threadReads, messageVersions, agentEmployees, userGroups, userGroupMembers, orgMembers, files, messageAttachments as messageAttachmentLinks } from '@deft/db/schema'; import { getIO, emitToUser } from '../socket.js'; import { parseMentions } from '../lib/mentions.js'; @@ -17,6 +18,7 @@ import { dispatchAgentEmployeeMessage } from '../lib/dispatch-agent-message.js'; import { toPlainText } from '../lib/plain-text.js'; import { getMessageAttachments, + type MessageAttachment, MAX_MESSAGE_ATTACHMENTS, normalizeAttachmentIds, toMessageAttachment, @@ -459,21 +461,26 @@ messageRoutes.post('/:spaceId', async (c) => { }, 400); } - const { message, messageAttachments } = await db.transaction(async (tx) => { - const [insertedMessage] = await tx.insert(messages).values({ + let messageAttachments: MessageAttachment[] = []; + const { value: message, replayed } = await nativeCreate({ + orgId: user.org_id, userId: user.id, operation: `message:${spaceId}`, + key: nativeCreateKey(c.req.header('Idempotency-Key')), payload: parsed.data, + replay: async (tx, id) => (await tx.select().from(messages).where(and(eq(messages.id, id), eq(messages.org_id, user.org_id), eq(messages.space_id, spaceId), eq(messages.user_id, user.id), eq(messages.is_deleted, false))).limit(1))[0], + create: async (tx) => { + const [insertedMessage] = await tx.insert(messages).values({ org_id: user.org_id, space_id: spaceId, user_id: user.id, content: normalizedContent, parent_id: parsed.data.parent_id, - }).returning(); - if (!insertedMessage) throw new Error('Message insert returned no row'); + }).returning(); + if (!insertedMessage) throw new Error('Message insert returned no row'); - if (attachmentIds.length === 0) { - return { message: insertedMessage, messageAttachments: [] }; - } + if (attachmentIds.length === 0) { + return insertedMessage; + } - const claimedFiles = await tx.update(files) + const claimedFiles = await tx.update(files) .set({ message_id: insertedMessage.id, staged_expires_at: null }) .where(and( inArray(files.id, attachmentIds), @@ -489,23 +496,22 @@ messageRoutes.post('/:spaceId', async (c) => { size_bytes: files.size_bytes, }); - if (claimedFiles.length !== attachmentIds.length) { + if (claimedFiles.length !== attachmentIds.length) { throw new AttachmentClaimError('One or more attachments are unavailable'); - } + } - await tx.insert(messageAttachmentLinks).values(attachmentIds.map((fileId, position) => ({ + await tx.insert(messageAttachmentLinks).values(attachmentIds.map((fileId, position) => ({ org_id: user.org_id, message_id: insertedMessage.id, file_id: fileId, position, - }))); + }))); - const claimedById = new Map(claimedFiles.map((file) => [file.id, file])); - return { - message: insertedMessage, - messageAttachments: attachmentIds.map((id) => toMessageAttachment(claimedById.get(id)!)), - }; - }); + const claimedById = new Map(claimedFiles.map((file) => [file.id, file])); + messageAttachments = attachmentIds.map((id) => toMessageAttachment(claimedById.get(id)!)); + return insertedMessage; + } }); + if (replayed) messageAttachments = (await getMessageAttachments([message.id], user.org_id)).get(message.id) ?? []; // Get user info for the broadcast const [userData] = await db.select({ @@ -524,6 +530,8 @@ messageRoutes.post('/:spaceId', async (c) => { files: messageAttachments, }; + if (replayed) return c.json(messageWithUser, 201); + // Broadcast via Socket.io const io = getIO(); if (io) { @@ -837,6 +845,7 @@ messageRoutes.post('/:spaceId', async (c) => { if (err instanceof AttachmentClaimError) { return c.json({ error: err.message, code: 'ATTACHMENT_NOT_FOUND' }, 404); } + if (err instanceof NativeCreateError) return c.json({ error: err.message, code: err.code }, err.status); console.error('Failed to send message:', err); return c.json({ error: 'Failed to send message', code: 'INTERNAL_ERROR' }, 500); } diff --git a/apps/api/src/routes/oauth-mcp.ts b/apps/api/src/routes/oauth-mcp.ts index 096fbd72..512e6bcf 100644 --- a/apps/api/src/routes/oauth-mcp.ts +++ b/apps/api/src/routes/oauth-mcp.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; import { z } from 'zod'; import { and, desc, eq, isNotNull, isNull, sql } from 'drizzle-orm'; import { db } from '../lib/db.js'; @@ -22,21 +23,49 @@ import { } from '../lib/oauth-mcp.js'; import { isHttpsPublicUrl } from '../lib/public-url.js'; import { enrichOAuthAuditActions } from '../lib/oauth-audit-receipts.js'; +import { oauthPublicGlobalLimiter, oauthPublicIpLimiter } from '../middleware/rate-limit.js'; export const oauthWellKnownRoutes = new Hono(); export const oauthPublicRoutes = new Hono(); export const oauthProtectedRoutes = new Hono(); +const httpsMetadataUrl = z.string().max(2048).url().refine((value) => new URL(value).protocol === 'https:', { + message: 'Metadata URLs must use HTTPS', +}); + const dcrSchema = z.object({ - client_name: z.string().min(1).max(160).default('Remote AI app'), + client_name: z.string().trim().min(1).max(160).default('Remote AI app'), application_type: z.enum(['native', 'web']).optional(), - client_uri: z.string().url().optional(), - logo_uri: z.string().url().optional(), - redirect_uris: z.array(z.string().url()).min(1).max(20), - grant_types: z.array(z.string()).optional(), - response_types: z.array(z.string()).optional(), - token_endpoint_auth_method: z.string().optional(), - scope: z.string().optional(), + client_uri: httpsMetadataUrl.optional(), + logo_uri: httpsMetadataUrl.optional(), + redirect_uris: z.array(z.string().max(2048).url()).min(1).max(20), + grant_types: z.array(z.enum(['authorization_code', 'refresh_token'])).min(1).max(2).optional(), + response_types: z.array(z.literal('code')).length(1).optional(), + token_endpoint_auth_method: z.literal('none').optional(), + scope: z.string().max(1024).optional(), +}); + +const tokenSchema = z.discriminatedUnion('grant_type', [ + z.object({ + grant_type: z.literal('authorization_code'), + client_id: z.string().min(1).max(256), + code: z.string().min(1).max(8192), + redirect_uri: z.string().max(2048).url(), + code_verifier: z.string().min(16).max(256), + resource: z.string().max(2048).url().optional(), + }), + z.object({ + grant_type: z.literal('refresh_token'), + client_id: z.string().min(1).max(256), + refresh_token: z.string().min(1).max(8192), + resource: z.string().max(2048).url().optional(), + }), +]); + +const revokeSchema = z.object({ + token: z.string().max(8192).optional(), + token_type_hint: z.enum(['access_token', 'refresh_token']).optional(), + client_id: z.string().min(1).max(256).optional(), }); const authorizeSchema = z.object({ @@ -63,10 +92,16 @@ function oauthError(c: any, err: unknown) { if (err instanceof OAuthMcpError) { return c.json({ error: err.code, error_description: err.message }, err.status); } - const message = err instanceof Error ? err.message : String(err); - return c.json({ error: 'server_error', error_description: message }, 500); + console.error('[oauth-mcp] unexpected request failure'); + return c.json({ error: 'server_error', error_description: 'OAuth request failed' }, 500); } +oauthPublicRoutes.use('*', bodyLimit({ + maxSize: 64 * 1024, + onError: (c) => c.json({ error: 'invalid_request', error_description: 'Request body is too large' }, 413), +})); +oauthPublicRoutes.use('*', oauthPublicGlobalLimiter, oauthPublicIpLimiter); + oauthWellKnownRoutes.get('/oauth-protected-resource', (c) => { const urls = metadataUrls(); return c.json({ @@ -97,7 +132,7 @@ oauthWellKnownRoutes.get('/oauth-authorization-server', (c) => { oauthPublicRoutes.post('/register', async (c) => { try { - const body = await c.req.json().catch(() => ({})); + const body = await c.req.json().catch(() => null); const parsed = dcrSchema.safeParse(body); if (!parsed.success) { return c.json({ error: 'invalid_client_metadata', error_description: 'Invalid client metadata', details: parsed.error.flatten() }, 400); @@ -125,7 +160,7 @@ oauthPublicRoutes.post('/register', async (c) => { grant_types: grantTypes, response_types: responseTypes, token_endpoint_auth_method: authMethod, - metadata: body, + metadata: parsed.data, }); await auditOAuth({ clientId, event: 'client_registered', metadata: { client_name: parsed.data.client_name } }); return c.json({ @@ -150,24 +185,23 @@ oauthPublicRoutes.post('/register', async (c) => { oauthPublicRoutes.post('/token', async (c) => { try { const body = await requestBody(c); - const grantType = String(body.grant_type ?? ''); - const clientId = String(body.client_id ?? ''); - if (!clientId) throw new OAuthMcpError(400, 'invalid_client', 'client_id is required'); - if (grantType === 'authorization_code') { + const parsed = tokenSchema.safeParse(body); + if (!parsed.success) throw new OAuthMcpError(400, 'invalid_request', 'Invalid token request'); + if (parsed.data.grant_type === 'authorization_code') { const result = await exchangeAuthorizationCode({ - code: String(body.code ?? ''), - clientId, - redirectUri: String(body.redirect_uri ?? ''), - codeVerifier: String(body.code_verifier ?? ''), - resource: body.resource ? String(body.resource) : null, + code: parsed.data.code, + clientId: parsed.data.client_id, + redirectUri: parsed.data.redirect_uri, + codeVerifier: parsed.data.code_verifier, + resource: parsed.data.resource ?? null, }); return c.json(result); } - if (grantType === 'refresh_token') { + if (parsed.data.grant_type === 'refresh_token') { const result = await refreshOAuthAccessToken({ - refreshToken: String(body.refresh_token ?? ''), - clientId, - resource: body.resource ? String(body.resource) : null, + refreshToken: parsed.data.refresh_token, + clientId: parsed.data.client_id, + resource: parsed.data.resource ?? null, }); return c.json(result); } @@ -180,7 +214,9 @@ oauthPublicRoutes.post('/token', async (c) => { oauthPublicRoutes.post('/revoke', async (c) => { try { const body = await requestBody(c); - const token = String(body.token ?? ''); + const parsed = revokeSchema.safeParse(body); + if (!parsed.success) throw new OAuthMcpError(400, 'invalid_request', 'Invalid revocation request'); + const token = parsed.data.token ?? ''; if (!token) return c.json({ ok: true }); const tokenHash = sha256(token); await db.update(oauthAccessTokens).set({ revoked_at: new Date() }).where(eq(oauthAccessTokens.token_hash, tokenHash)); diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts index 53808d34..9ff2995c 100644 --- a/apps/api/src/routes/projects.ts +++ b/apps/api/src/routes/projects.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { z } from 'zod'; import { eq, and, desc, gt, sql, inArray, isNull } from 'drizzle-orm'; import { db } from '../lib/db.js'; +import { nativeCreate, nativeCreateKey, NativeCreateError } from '../lib/native-create.js'; import { projects, tasks, taskLabels, labels, users, taskActivity } from '@deft/db/schema'; import { getIO, emitToUser } from '../socket.js'; import { enqueue, QUEUE_NAMES } from '../lib/queues.js'; @@ -612,35 +613,46 @@ projectRoutes.post('/:id/tasks', async (c) => { return c.json({ error: 'Assignee must be an active user or healthy agent in this organization', code: 'INVALID_ASSIGNEE' }, 400); } - const taskNumber = await reserveNextTaskNumber({ - projectId, - orgId: user.org_id, - }); + const { value: task, replayed } = await nativeCreate({ + orgId: user.org_id, userId: user.id, operation: `task:${projectId}`, + key: nativeCreateKey(c.req.header('Idempotency-Key')), payload: parsed.data, + replay: async (tx, id) => (await tx.select().from(tasks).where(and(eq(tasks.id, id), eq(tasks.org_id, user.org_id), eq(tasks.project_id, projectId), eq(tasks.created_by, user.id), eq(tasks.is_deleted, false))).limit(1))[0], + create: async (tx) => { + const taskNumber = await reserveNextTaskNumber({ + projectId, + orgId: user.org_id, + executor: tx, + }); - const [task] = await db.insert(tasks).values({ - org_id: user.org_id, - project_id: projectId, - number: taskNumber, - title: parsed.data.title, - description: parsed.data.description || undefined, - status: (parsed.data.status || 'backlog') as any, - priority: (parsed.data.priority || 'p2') as any, - assignee_id: assigneeId ?? undefined, - created_by: user.id, - due_date: parsed.data.due_date ? new Date(parsed.data.due_date) : undefined, - sort_order: parsed.data.sort_order ?? 0, - source_message_id: parsed.data.source_message_id || undefined, - parent_task_id: parsed.data.parent_task_id || undefined, - metadata: parsed.data.metadata ?? undefined, - }).returning(); + const [task] = await tx.insert(tasks).values({ + org_id: user.org_id, + project_id: projectId, + number: taskNumber, + title: parsed.data.title, + description: parsed.data.description || undefined, + status: (parsed.data.status || 'backlog') as any, + priority: (parsed.data.priority || 'p2') as any, + assignee_id: assigneeId ?? undefined, + created_by: user.id, + due_date: parsed.data.due_date ? new Date(parsed.data.due_date) : undefined, + sort_order: parsed.data.sort_order ?? 0, + source_message_id: parsed.data.source_message_id || undefined, + parent_task_id: parsed.data.parent_task_id || undefined, + metadata: parsed.data.metadata ?? undefined, + }).returning(); + + // Create activity log entry + await tx.insert(taskActivity).values({ + org_id: user.org_id, + task_id: task!.id, + user_id: user.id, + action: 'created', + }); - // Create activity log entry - await db.insert(taskActivity).values({ - org_id: user.org_id, - task_id: task!.id, - user_id: user.id, - action: 'created', - }); + return task!; + } }); + if (replayed) return c.json({ ...task, project_prefix: project.prefix, project_name: project.name }, 201); + const taskNumber = task.number; // Broadcast task:created via socket to org const io = getIO(); @@ -701,6 +713,7 @@ projectRoutes.post('/:id/tasks', async (c) => { project_name: project.name, }, 201); } catch (err) { + if (err instanceof NativeCreateError) return c.json({ error: err.message, code: err.code }, err.status); console.error('Failed to create task:', err); return c.json({ error: 'Failed to create task', code: 'INTERNAL_ERROR' }, 500); } diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts index b0995f06..91ca9b44 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { z } from 'zod'; import { eq, and, desc, asc, sql, inArray, ilike, or, isNull, type SQL } from 'drizzle-orm'; import { db } from '../lib/db.js'; +import { nativeCreate, nativeCreateKey, NativeCreateError } from '../lib/native-create.js'; import { projects, tasks, taskComments, taskActivity, taskLabels, labels, users, projectSpaces, messages, taskRelationships, files, taskAttachments, savedViews, taskWatchers, taskAssignees, taskReactions, wikiPages, wikiCitations, orgMembers, workflowRules, agentEmployees, agentActions, agentChannelDeliveryAttempts, agentChannelEvents, agentCooperativeLog, spaces, spaceMembers } from '@deft/db/schema'; import { getIO, emitToUser } from '../socket.js'; import { enqueue, QUEUE_NAMES } from '../lib/queues.js'; @@ -1772,6 +1773,7 @@ async function createTaskForProject( projectId: string, orgId: string, userId: string, + requestKey?: string, ): Promise<{ task: Record; project: { prefix: string; name: string } }> { // Verify project belongs to org const [project] = await db.select() @@ -1793,33 +1795,42 @@ async function createTaskForProject( throw Object.assign(new Error('Invalid assignee'), { code: 'INVALID_ASSIGNEE' }); } - const taskNumber = await reserveNextTaskNumber({ projectId, orgId }); - - const [task] = await db.insert(tasks).values({ - org_id: orgId, - project_id: projectId, - number: taskNumber, - title: data.title, - description: data.description || undefined, - status: (data.status || 'backlog') as any, - priority: (data.priority || 'p2') as any, - assignee_id: assigneeId ?? undefined, - created_by: userId, - due_date: data.due_date ? new Date(data.due_date) : undefined, - sort_order: data.sort_order ?? 0, - source_message_id: data.source_message_id || undefined, - parent_task_id: data.parent_task_id || undefined, - // Task 4.11 — skill-defined custom fields. - metadata: data.metadata ?? undefined, - }).returning(); - - // Create activity log entry - await db.insert(taskActivity).values({ - org_id: orgId, - task_id: task!.id, - user_id: userId, - action: 'created', - }); + const { value: task, replayed } = await nativeCreate({ + orgId, userId, operation: `task:${projectId}`, key: requestKey, payload: data, + replay: async (tx, id) => (await tx.select().from(tasks).where(and(eq(tasks.id, id), eq(tasks.org_id, orgId), eq(tasks.project_id, projectId), eq(tasks.created_by, userId), eq(tasks.is_deleted, false))).limit(1))[0], + create: async (tx) => { + const taskNumber = await reserveNextTaskNumber({ projectId, orgId, executor: tx }); + + const [task] = await tx.insert(tasks).values({ + org_id: orgId, + project_id: projectId, + number: taskNumber, + title: data.title, + description: data.description || undefined, + status: (data.status || 'backlog') as any, + priority: (data.priority || 'p2') as any, + assignee_id: assigneeId ?? undefined, + created_by: userId, + due_date: data.due_date ? new Date(data.due_date) : undefined, + sort_order: data.sort_order ?? 0, + source_message_id: data.source_message_id || undefined, + parent_task_id: data.parent_task_id || undefined, + // Task 4.11 — skill-defined custom fields. + metadata: data.metadata ?? undefined, + }).returning(); + + // Create activity log entry + await tx.insert(taskActivity).values({ + org_id: orgId, + task_id: task!.id, + user_id: userId, + action: 'created', + }); + + return task!; + } }); + if (replayed) return { task, project }; + const taskNumber = task.number; // Broadcast task:created via socket to org const io = getIO(); @@ -1904,7 +1915,7 @@ taskRoutes.post('/', async (c) => { const { project_id: projectId, ...taskData } = parsed.data; try { - const { task, project } = await createTaskForProject(taskData, projectId, user.org_id, user.id); + const { task, project } = await createTaskForProject(taskData, projectId, user.org_id, user.id, nativeCreateKey(c.req.header('Idempotency-Key'))); return c.json({ ...task, project_prefix: project.prefix, project_name: project.name }, 201); } catch (err: any) { if (err?.code === 'NOT_FOUND') { @@ -1916,6 +1927,7 @@ taskRoutes.post('/', async (c) => { throw err; } } catch (err) { + if (err instanceof NativeCreateError) return c.json({ error: err.message, code: err.code }, err.status); console.error('Failed to create task:', err); return c.json({ error: 'Failed to create task', code: 'INTERNAL_ERROR' }, 500); } @@ -1935,7 +1947,7 @@ taskRoutes.post('/project/:projectId', async (c) => { } try { - const { task, project } = await createTaskForProject(parsed.data, projectId, user.org_id, user.id); + const { task, project } = await createTaskForProject(parsed.data, projectId, user.org_id, user.id, nativeCreateKey(c.req.header('Idempotency-Key'))); return c.json({ ...task, project_prefix: project.prefix, project_name: project.name }, 201); } catch (err: any) { if (err?.code === 'NOT_FOUND') { @@ -1944,6 +1956,7 @@ taskRoutes.post('/project/:projectId', async (c) => { throw err; } } catch (err) { + if (err instanceof NativeCreateError) return c.json({ error: err.message, code: err.code }, err.status); console.error('Failed to create task:', err); return c.json({ error: 'Failed to create task', code: 'INTERNAL_ERROR' }, 500); } diff --git a/apps/api/src/socket.ts b/apps/api/src/socket.ts index c26c8678..fcd4930f 100644 --- a/apps/api/src/socket.ts +++ b/apps/api/src/socket.ts @@ -1,7 +1,6 @@ import { Server as SocketIOServer } from 'socket.io'; import type { Server as HTTPServer } from 'node:http'; -import jwt from 'jsonwebtoken'; -import { env } from './lib/env.js'; +import { verifyWebAccess, webSessionEvents } from './lib/web-sessions.js'; import { db } from './lib/db.js'; import { users, orgMembers, spaceMembers, spaces } from '@deft/db/schema'; import { eq, and, ne } from 'drizzle-orm'; @@ -13,6 +12,8 @@ export type SocketUser = { id: string; email: string; org_id: string; + sid?: string; + exp?: number; role?: 'owner' | 'admin' | 'member' | 'guest'; }; type HuddleEventName = 'huddle:create' | 'huddle:list' | 'huddle:join' | 'huddle:leave' | 'huddle:signal' | 'huddle:mute'; @@ -264,7 +265,19 @@ export function getUserStatus(userId: string): 'online' | 'idle' | 'offline' { return 'offline'; } -export function setupSocket(server: HTTPServer) { +type SocketSetupDependencies = { + verifyAccess?: typeof verifyWebAccess; + requireMembership?: typeof requireActiveOrgMembership; + getSpaceAccess?: typeof getHuddleSpaceAccess; + recordLastSeen?: typeof updateLastSeen; + onPacketIngress?: (eventName: string) => void; +}; + +export function setupSocket(server: HTTPServer, dependencies: SocketSetupDependencies = {}) { + const verifyAccess = dependencies.verifyAccess ?? verifyWebAccess; + const requireMembership = dependencies.requireMembership ?? requireActiveOrgMembership; + const getSpaceAccess = dependencies.getSpaceAccess ?? getHuddleSpaceAccess; + const recordLastSeen = dependencies.recordLastSeen ?? updateLastSeen; io = new SocketIOServer(server, { cors: { origin: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', @@ -275,15 +288,18 @@ export function setupSocket(server: HTTPServer) { }); // Auth middleware + const disconnectSession = (sid: string) => io?.in(`web-session:${sid}`).disconnectSockets(true); + webSessionEvents.on('revoked', disconnectSession); + server.once('close', () => webSessionEvents.off('revoked', disconnectSession)); io.use(async (socket, next) => { const token = socket.handshake.auth.token; if (!token) { return next(new Error('Authentication required')); } try { - const payload = jwt.verify(token, env.JWT_SECRET) as { id: string; email: string; org_id: string }; + const payload = await verifyAccess(token); const authorizationGeneration = captureRealtimeAccessGeneration(); - const membership = await requireActiveOrgMembership(payload.org_id, payload.id); + const membership = await requireMembership(payload.org_id, payload.id); if (!isRealtimeAccessGenerationCurrent(authorizationGeneration)) { return next(new Error('Workspace access changed; reconnect')); } @@ -296,27 +312,73 @@ export function setupSocket(server: HTTPServer) { }); io.on('connection', async (socket) => { + let settleInitialization!: (ready: boolean) => void; + let initializationSettled = false; + const initializationReady = new Promise((resolve) => { + settleInitialization = (ready) => { + if (initializationSettled) return; + initializationSettled = true; + resolve(ready); + }; + }); + socket.once('disconnect', () => settleInitialization(false)); + socket.use(async (event, next) => { + dependencies.onPacketIngress?.(String(event[0] ?? '')); + const packetGeneration = captureRealtimeAccessGeneration(); + try { + if (!await initializationReady || !socket.connected) { + next(new Error('Socket initialization failed')); + return; + } + await verifyAccess(socket.handshake.auth.token); + if (!socket.connected || !isRealtimeAccessGenerationCurrent(packetGeneration)) { + socket.disconnect(true); + next(new Error('Workspace access changed; reconnect')); + return; + } + next(); + } catch { + settleInitialization(false); + socket.disconnect(true); + next(new Error('Session unavailable; reconnect')); + } + }); const user = (socket as any).user as SocketUser; + const expiryTimer = setTimeout(() => socket.disconnect(true), Math.max(0, (user.exp ?? 0) * 1000 - Date.now())); + expiryTimer.unref(); + socket.once('disconnect', () => clearTimeout(expiryTimer)); const connectionGeneration = (socket as any).realtimeAccessGeneration as number; if (!socket.connected || !isRealtimeAccessGenerationCurrent(connectionGeneration)) { + settleInitialization(false); socket.disconnect(true); return; } try { - const initialRooms = [ + // Join only the session-control room until the second verification has + // closed the handshake/revocation race. Sensitive data rooms follow. + await socket.join(`web-session:${user.sid}`); + await verifyAccess(socket.handshake.auth.token); + if (!socket.connected || !isRealtimeAccessGenerationCurrent(connectionGeneration)) { + settleInitialization(false); + socket.disconnect(true); + return; + } + const dataRooms = [ `org:${user.org_id}`, `user:${user.id}`, `org-user:${user.org_id}:${user.id}`, ]; - if (user.role !== 'guest') initialRooms.push(`org-members:${user.org_id}`); - await socket.join(initialRooms); + if (user.role !== 'guest') dataRooms.push(`org-members:${user.org_id}`); + await socket.join(dataRooms); } catch (error) { console.error('Failed to initialize socket rooms:', error); + settleInitialization(false); socket.disconnect(true); return; } if (!socket.connected || !isRealtimeAccessGenerationCurrent(connectionGeneration)) { + settleInitialization(false); socket.disconnect(true); return; } @@ -334,7 +396,7 @@ export function setupSocket(server: HTTPServer) { idleUsers.delete(user.id); // Update last_seen_at on connect - updateLastSeen(user.id); + recordLastSeen(user.id); // Broadcast online to org (only if this is the first socket for this user) if (onlineUsers.get(user.id)!.size === 1) { @@ -357,7 +419,7 @@ export function setupSocket(server: HTTPServer) { socket.on('space:join', async (spaceId: string) => { if (typeof spaceId !== 'string' || spaceId.length === 0 || spaceId.length > 128) return; const authorizationGeneration = captureRealtimeAccessGeneration(); - const access = await getHuddleSpaceAccess(spaceId, user).catch(() => undefined); + const access = await getSpaceAccess(spaceId, user).catch(() => undefined); if (!access || !isRealtimeAccessGenerationCurrent(authorizationGeneration)) return; try { await socket.join(`space:${spaceId}`); @@ -414,7 +476,7 @@ export function setupSocket(server: HTTPServer) { }); socket.on('presence:active', () => { - updateLastSeen(user.id); + recordLastSeen(user.id); if (idleUsers.has(user.id)) { idleUsers.delete(user.id); socket.to(`org:${user.org_id}`).emit('presence:update', { @@ -458,7 +520,7 @@ export function setupSocket(server: HTTPServer) { const authorizationGeneration = captureRealtimeAccessGeneration(); let access: Awaited>; try { - access = await getHuddleSpaceAccess(data.space_id, user); + access = await getSpaceAccess(data.space_id, user); } catch (error) { console.error('Failed to authorize huddle creation:', error); emitHuddleError('huddle:create', 'INTERNAL_ERROR', 'Unable to start huddle'); @@ -606,7 +668,7 @@ export function setupSocket(server: HTTPServer) { const authorizationGeneration = captureRealtimeAccessGeneration(); let access: Awaited>; try { - access = await getHuddleSpaceAccess(room.space_id, user); + access = await getSpaceAccess(room.space_id, user); } catch (error) { console.error('Failed to authorize huddle join:', error); emitHuddleError('huddle:join', 'INTERNAL_ERROR', 'Unable to join huddle'); @@ -829,7 +891,7 @@ export function setupSocket(server: HTTPServer) { if (sockets.size === 0) { onlineUsers.delete(user.id); idleUsers.delete(user.id); - updateLastSeen(user.id); + recordLastSeen(user.id); socket.to(`org:${user.org_id}`).emit('presence:update', { user_id: user.id, status: 'offline', @@ -837,6 +899,10 @@ export function setupSocket(server: HTTPServer) { } } }); + // Socket.IO awaits per-packet middleware before dispatching to listeners. + // Packets emitted from the client's `connect` callback remain queued here + // until initialization and every handler registration above are complete. + settleInitialization(true); }); return io; diff --git a/apps/api/test/agent-actions-routes.test.ts b/apps/api/test/agent-actions-routes.test.ts index 12bb542d..99f8596a 100644 --- a/apps/api/test/agent-actions-routes.test.ts +++ b/apps/api/test/agent-actions-routes.test.ts @@ -221,6 +221,29 @@ async function insertPendingTaskCreate(title: string): Promise { }); } +async function insertPendingLegacyTaskCreate(title: string): Promise { + return withClient(async (c) => { + const r = await c.query( + `INSERT INTO agent_actions + (id, org_id, user_id, agent_employee_id, source, action, params, + approval_tier, approval_status) + VALUES (gen_random_uuid()::text, $1, $2, NULL, 'defty', 'create_task', $3::jsonb, 'quick', 'pending') + RETURNING id`, + [ + ORG_ID, + SHADOW_USER_ID, + JSON.stringify({ + title, + project_name: 'Actions Routes Test Project', + resolved_project_id: TEST_PROJECT_ID, + priority: 'p2', + }), + ], + ); + return r.rows[0].id as string; + }); +} + async function insertPendingModuleCreate(sentinel: string, idempotencyKey: string): Promise { return withClient(async (c) => { const action = await c.query( @@ -1462,3 +1485,34 @@ test('double approve is idempotent via HTTP', async () => { assert.equal(t.rows[0].n, 1, 'no double task insert'); }); }); + +test('legacy Defty create_task approval writes one verified receipt across retries', async () => { + const title = `routes-legacy-receipt-${Date.now()}`; + const actionId = await insertPendingLegacyTaskCreate(title); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const response = await app().request( + `/api/agent/actions/${actionId}/approve`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }, + ); + assert.equal(response.status, 200); + } + + const receiptResponse = await app().request(`/api/agent/actions/${actionId}/receipt`); + assert.equal(receiptResponse.status, 200, 'approved legacy action should expose a receipt'); + const receiptBody = await receiptResponse.json() as { verified?: boolean }; + assert.equal(receiptBody.verified, true); + + await withClient(async (c) => { + const [taskCount, receiptCount] = await Promise.all([ + c.query(`SELECT COUNT(*)::int AS n FROM tasks WHERE title = $1`, [title]), + c.query(`SELECT COUNT(*)::int AS n FROM action_receipts WHERE action_id = $1`, [actionId]), + ]); + assert.equal(taskCount.rows[0].n, 1, 'repeated approval must not duplicate the task'); + assert.equal(receiptCount.rows[0].n, 1, 'repeated approval must not duplicate the receipt'); + }); +}); diff --git a/apps/api/test/app-automation-management-pagination.test.ts b/apps/api/test/app-automation-management-pagination.test.ts new file mode 100644 index 00000000..e4ad211c --- /dev/null +++ b/apps/api/test/app-automation-management-pagination.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + decodeAppAutomationManagementCursor, + projectAppAutomationManagementEligibility, + selectAppAutomationManagementPage, +} from '../src/lib/app-automation-management-service.js'; + +type Row = { id: string; app_installation_id: string; created_at: Date }; + +function olderRows(rows: readonly Row[], cursor: string | null, installationId: string): Row[] { + const after = decodeAppAutomationManagementCursor(cursor ?? undefined, installationId); + return rows.filter((row) => !after + || row.created_at < after.created_at + || (row.created_at.getTime() === after.created_at.getTime() && row.id < after.id)); +} + +test('management pagination reaches an old active schedule with equal timestamps and no duplicates', () => { + const installationId = 'installation-a'; + const createdAt = new Date('2026-09-05T00:00:00.000Z'); + const rows = Array.from({ length: 101 }, (_, index) => ({ + id: String(1000 - index).padStart(4, '0'), + app_installation_id: installationId, + created_at: createdAt, + })); + + const seen: string[] = []; + let cursor: string | null = null; + do { + const result = selectAppAutomationManagementPage(olderRows(rows, cursor, installationId), 50); + seen.push(...result.page.map((row) => row.id)); + cursor = result.next_cursor; + } while (cursor); + + assert.deepEqual(seen, rows.map((row) => row.id)); + assert.equal(new Set(seen).size, 101); +}); + +test('management distinguishes known expiry and revocation from the delivery-time stale check', () => { + const now = new Date('2026-09-05T12:00:00.000Z'); + const base = { valid_from: new Date('2026-09-01T00:00:00.000Z'), valid_until: new Date('2026-09-10T00:00:00.000Z') }; + assert.equal(projectAppAutomationManagementEligibility({ ...base, state: 'active' } as never, now, true).status, 'awaiting_delivery_check'); + assert.equal(projectAppAutomationManagementEligibility({ ...base, state: 'active', valid_until: now } as never, now, true).status, 'expired'); + assert.equal(projectAppAutomationManagementEligibility({ ...base, state: 'revoked' } as never, now, true).status, 'revoked'); + assert.equal(projectAppAutomationManagementEligibility({ ...base, state: 'active' } as never, now, false).status, 'delivery_disabled'); +}); + +test('management cursor rejects malformed and cross-installation pages', () => { + assert.throws( + () => decodeAppAutomationManagementCursor('not-a-cursor', 'installation-a'), + /Invalid App automation cursor/, + ); + const cursor = selectAppAutomationManagementPage([{ + id: 'definition-2', app_installation_id: 'installation-a', created_at: new Date('2026-09-05T00:00:00.000Z'), + }, { + id: 'definition-1', app_installation_id: 'installation-a', created_at: new Date('2026-09-05T00:00:00.000Z'), + }], 1).next_cursor; + assert.ok(cursor); + assert.throws( + () => decodeAppAutomationManagementCursor(cursor ?? undefined, 'installation-b'), + /Invalid App automation cursor/, + ); +}); diff --git a/apps/api/test/app-platform-sandbox-provider.test.ts b/apps/api/test/app-platform-sandbox-provider.test.ts index d0ac65f3..051c3cb2 100644 --- a/apps/api/test/app-platform-sandbox-provider.test.ts +++ b/apps/api/test/app-platform-sandbox-provider.test.ts @@ -99,6 +99,7 @@ test('packed standalone sandbox provider conforms through the official stdio tra ); assert.deepEqual(await packedPayloadProof(installedProviderRoot), providerPin.artifact.content); const serverPath = resolve(installedProviderRoot, 'server.mjs'); + const outboxPath = resolve(temporaryRoot, 'sandbox-outbox.jsonl'); assert.match(await readFile(serverPath, 'utf8'), /server\/discover/); process.env.DEFT_SELF_HOSTED = 'true'; process.env.DEFT_MCP_ENABLE_UNSAFE_STDIO = 'true'; @@ -109,7 +110,7 @@ test('packed standalone sandbox provider conforms through the official stdio tra orgId: '00000000-0000-4000-8000-000000000006', transport: 'stdio' as const, command: process.execPath, - args: [serverPath], + args: [serverPath, '--outbox-file', outboxPath], }; const discovery = await manager.testToolDiscovery(config); @@ -136,6 +137,34 @@ test('packed standalone sandbox provider conforms through the official stdio tra ); assert.equal(replay.success, true, replay.error); assert.deepEqual(replay.structuredContent, first.structuredContent); + const outbox = (await readFile(outboxPath, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); + assert.equal(outbox.length, 1, 'one durable provider effect is recorded before the accepted response'); + assert.deepEqual(outbox[0], { + schema_version: 'deft.app_platform.sandbox_email.outbox.v1', + idempotency_key: SANDBOX_EMAIL_SEND_CONFORMANCE_VECTORS.valid.input.idempotency_key, + digest: createHash('sha256').update([ + SANDBOX_EMAIL_SEND_CONFORMANCE_VECTORS.valid.input.to, + SANDBOX_EMAIL_SEND_CONFORMANCE_VECTORS.valid.input.subject, + SANDBOX_EMAIL_SEND_CONFORMANCE_VECTORS.valid.input.body_text, + SANDBOX_EMAIL_SEND_CONFORMANCE_VECTORS.valid.input.idempotency_key, + ].join('\u0000')).digest('hex'), + message_id: SANDBOX_EMAIL_SEND_CONFORMANCE_VECTORS.valid.output.message_id, + }); + + await manager.shutdown(); + const restartedManager = new MCPClientManager(); + try { + const restarted = await restartedManager.executeTool( + config, + 'send_email', + { ...SANDBOX_EMAIL_SEND_CONFORMANCE_VECTORS.valid.input }, + ); + assert.equal(restarted.success, true, restarted.error); + assert.deepEqual(restarted.structuredContent, first.structuredContent); + assert.equal((await readFile(outboxPath, 'utf8')).trim().split('\n').length, 1); + } finally { + await restartedManager.shutdown(); + } const conflict = await manager.executeTool(config, 'send_email', { ...SANDBOX_EMAIL_SEND_CONFORMANCE_VECTORS.valid.input, diff --git a/apps/api/test/apps-connected-grants-db.test.ts b/apps/api/test/apps-connected-grants-db.test.ts index 43b016b8..4633c491 100644 --- a/apps/api/test/apps-connected-grants-db.test.ts +++ b/apps/api/test/apps-connected-grants-db.test.ts @@ -1,5 +1,9 @@ +import './fixtures/app-run-enabled-env.js'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; import { after, before, test } from 'node:test'; import { and, count, eq, inArray } from 'drizzle-orm'; import { Hono } from 'hono'; @@ -16,10 +20,12 @@ import { import { agentActions, appActionBindings, + appAutomationDefinitions, appDependencyLocks, appGrantSnapshots, appInstallations, appModuleBindings, + appRunReceipts, appRuns, appVersions, capabilityProviderSnapshots, @@ -37,6 +43,7 @@ import { } from '@deft/db/schema'; import { AppError } from '../src/lib/app-errors.js'; import { AppActionService } from '../src/lib/app-action-service.js'; +import { CapabilityService } from '../src/lib/capability-service.js'; import { createReviewedAppAutomationDefinition, pauseAppAutomationDefinition, @@ -59,7 +66,12 @@ import { PostgresAppRunRepository } from '../src/lib/app-run-repository.js'; import { AppRunSecretRepository } from '../src/lib/app-run-secret-repository.js'; import { AppRunSecretService } from '../src/lib/app-run-secrets.js'; import { AppRunService } from '../src/lib/app-run-service.js'; +import { listManagedAppAutomations } from '../src/lib/app-automation-management-service.js'; import { closeDb, db } from '../src/lib/db.js'; +import { runAppAutomationFire, runAppAutomationScan } from '../src/lib/app-automation-runtime.js'; +import { completeJob, dequeueJob, QUEUE_NAMES } from '../src/lib/queues.js'; +import { handleAppRunAttempt } from '../src/lib/app-run-worker-handler.js'; +import { getAppRunRuntime, shutdownAppRunRuntime } from '../src/lib/app-run-runtime.js'; import { ModuleError } from '../src/lib/module-errors.js'; import { activateAppInstallation, @@ -90,7 +102,7 @@ import { buildPhase5DependencyAppPackage, buildTrackAAutomatedConnectedAppPackage, } from './fixtures/phase5-connected-app-package.js'; -import { databaseCompleteAppRunTestKeyrings } from './fixtures/app-run-test-keyrings.js'; +import { databaseCompleteAppRunTestKeyringFixture } from './fixtures/app-run-test-keyrings.js'; const DATABASE_URL = process.env.DEFT_TEST_DATABASE_URL ?? (process.env.CI === 'true' ? process.env.DATABASE_URL : undefined); @@ -155,7 +167,7 @@ async function sandboxReviewCapability(orgId: string, connectionId: string) { operation_name: 'send_email', }, title: 'Send sandbox email', - description: 'Accept one deterministic sandbox email.', + description: 'Accept one deterministic sandbox email without network egress.', input_schema: SANDBOX_EMAIL_SEND_PRIVATE_CONTRACT.input_schema, output_schema: SANDBOX_EMAIL_SEND_PRIVATE_CONTRACT.output_schema, }], @@ -593,8 +605,29 @@ test('Protocol v1 staging writes one requested snapshot and no executable author }); test('Protocol v2 review and automation lifecycle converge on one governed Run', async (t) => { - const orgId = randomUUID(); + const capacityMode = process.env.DEFT_PREVIEW_CAPACITY_PROOF === 'true'; + const orgId = capacityMode ? '00000000-0000-4000-8000-000000000001' : randomUUID(); const userId = randomUUID(); + const providerRoot = resolve(import.meta.dirname, '..', '..', '..', 'examples', 'app-platform-sandbox-email-provider'); + const providerOutboxRoot = await mkdtemp(resolve(tmpdir(), 'deft-track-a-outbox-')); + const providerOutbox = resolve(providerOutboxRoot, 'effects.jsonl'); + const providerEnvironment = { + selfHosted: process.env.DEFT_SELF_HOSTED, + unsafeStdio: process.env.DEFT_MCP_ENABLE_UNSAFE_STDIO, + allowlist: process.env.MCP_STDIO_ALLOWED_COMMANDS, + }; + t.after(async () => { + if (providerEnvironment.selfHosted === undefined) delete process.env.DEFT_SELF_HOSTED; + else process.env.DEFT_SELF_HOSTED = providerEnvironment.selfHosted; + if (providerEnvironment.unsafeStdio === undefined) delete process.env.DEFT_MCP_ENABLE_UNSAFE_STDIO; + else process.env.DEFT_MCP_ENABLE_UNSAFE_STDIO = providerEnvironment.unsafeStdio; + if (providerEnvironment.allowlist === undefined) delete process.env.MCP_STDIO_ALLOWED_COMMANDS; + else process.env.MCP_STDIO_ALLOWED_COMMANDS = providerEnvironment.allowlist; + await rm(providerOutboxRoot, { recursive: true, force: true }); + }); + process.env.DEFT_SELF_HOSTED = 'true'; + process.env.DEFT_MCP_ENABLE_UNSAFE_STDIO = 'true'; + process.env.MCP_STDIO_ALLOWED_COMMANDS = process.execPath; await db.insert(orgs).values({ id: orgId, name: 'Protocol v2 lifecycle', @@ -659,13 +692,15 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', org_id: orgId, name: 'Protocol v2 sandbox mail', slug: `track-a-v2-mail-${randomUUID()}`, - server_url: 'https://track-a-v2.example.test/mcp', - transport: 'streamable-http', + server_url: null, + transport: 'stdio', + stdio_command: process.execPath, + stdio_args: [resolve(providerRoot, 'server.mjs'), '--outbox-file', providerOutbox], auth_type: 'none', is_active: true, created_by: userId, }); - const { capability } = await sandboxReviewCapability(orgId, connectionId); + const capability = new CapabilityService(); const request = { app_version_id: version.id, expected_package_digest: version.package_digest, @@ -783,6 +818,8 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', const createDefinition = async ( minuteOffset: number, maxOrgRunsPerUtcDay = 100, + createdAt = approvedAt, + validitySeconds = 24 * 60 * 60, ) => { const scheduledAt = new Date(scheduleBase + minuteOffset * 60_000); const input = { @@ -802,7 +839,7 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', }, local_time: scheduledAt.toISOString().slice(11, 16), timezone: 'UTC', - validity_seconds: 24 * 60 * 60, + validity_seconds: validitySeconds, max_org_runs_per_utc_day: maxOrgRunsPerUtcDay, max_pending_org_fires: 25, } as const; @@ -811,7 +848,7 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', ...input, expected_review_digest: review.review_digest, accept_code_owned_policy: true, - }, { now: () => approvedAt }); + }, { now: () => createdAt }); return { definition, scheduledAt }; }; const persistFire = ({ definition, scheduledAt }: Awaited>) => ( @@ -834,6 +871,205 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', assert.equal((definition.authorization_vector as any).approver.user_id, userId); assert.equal((definition.authorization_vector as any).relation.revision, relation.revision); + const newerDefinitions = await Promise.all(Array.from({ length: 100 }, async (_value, index) => { + const createdAt = new Date(approvedAt.getTime() + index + 1); + const created = await createDefinition(10 + index, 100, createdAt); + return pauseAppAutomationDefinition(actor, { + definition_id: created.definition.id, + expected_epoch: created.definition.definition_epoch, + }); + })); + assert.equal(newerDefinitions.length, 100); + const managedIds: string[] = []; + let managementCursor: string | undefined; + do { + const management = await listManagedAppAutomations(actor, staged.id, { + cursor: managementCursor, + limit: 50, + }, new Date(approvedAt.getTime() + 1)); + managedIds.push(...management.definitions.map((item) => item.id)); + managementCursor = management.next_cursor ?? undefined; + } while (managementCursor); + assert.ok(managedIds.includes(definition.id), 'older active automation stays discoverable after 100 newer paused definitions'); + assert.equal(new Set(managedIds).size, managedIds.length, 'management cursor pages do not duplicate definitions'); + + if (capacityMode) { + const currentDue = await Promise.all(Array.from({ length: 23 }, (_value, index) => ( + createDefinition(1, 100, new Date(approvedAt.getTime() + index + 1)) + ))); + const historical = await createDefinition( + 1, + 100, + new Date(approvedAt.getTime() - 29 * 24 * 60 * 60 * 1_000), + 30 * 24 * 60 * 60, + ); + const future = await Promise.all(Array.from({ length: 76 }, (_value, index) => ( + createDefinition(600, 100, new Date(approvedAt.getTime() + 100 + index)) + ))); + assert.equal(1 + currentDue.length + 1 + future.length, 101); + + const orgB = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; + const userB = randomUUID(); + await db.insert(orgs).values({ id: orgB, name: 'Capacity org B', slug: `capacity-b-${randomUUID()}` }); + await db.insert(users).values({ id: userB, email: `capacity-b-${randomUUID()}@example.test`, name: 'Capacity B owner' }); + await db.insert(orgMembers).values({ + id: randomUUID(), org_id: orgB, user_id: userB, role: 'owner', is_active: true, + }); + const actorB = humanModuleActor({ orgId: orgB, userId: userB, role: 'owner' }); + const dependencyB = await stageAppPackage(actorB, dependencyBuilt.json); + await activateAppInstallation(actorB, dependencyB.id, dependencyB.package_digest); + const stagedB = await stageAppPackage(actorB, built.json); + const [versionB] = await db.select().from(appVersions).where(and( + eq(appVersions.org_id, orgB), eq(appVersions.id, stagedB.version_id), + )); + const [requestedB] = await db.select().from(appGrantSnapshots).where(and( + eq(appGrantSnapshots.org_id, orgB), eq(appGrantSnapshots.id, versionB!.requested_grant_snapshot_id!), + )); + const connectionB = randomUUID(); + await db.insert(mcpConnections).values({ + id: connectionB, + org_id: orgB, + name: 'Capacity B sandbox mail', + slug: `capacity-b-mail-${randomUUID()}`, + server_url: null, + transport: 'stdio', + stdio_command: process.execPath, + stdio_args: [resolve(providerRoot, 'server.mjs'), '--outbox-file', providerOutbox], + auth_type: 'none', + is_active: true, + created_by: userB, + }); + const requestB = { + app_version_id: versionB!.id, + expected_package_digest: versionB!.package_digest, + expected_requested_snapshot_digest: requestedB!.snapshot_digest, + expected_lifecycle_epoch: stagedB.lifecycle_epoch, + expected_grant_epoch: stagedB.grant_epoch, + connector_selections: [{ connector_requirement_key: 'mail_provider', mcp_connection_id: connectionB }], + }; + const reviewB = await prepareConnectedAppReview(actorB, stagedB.id, requestB, capability); + await activateConnectedAppInstallation(actorB, stagedB.id, { + ...requestB, expected_review_digest: reviewB.review_digest, accept_host_policy: true, + }, capability); + const [campaignBindingB] = await db.select({ binding: appModuleBindings, version: moduleVersions }) + .from(appModuleBindings).innerJoin(moduleVersions, and( + eq(moduleVersions.org_id, appModuleBindings.org_id), + eq(moduleVersions.installation_id, appModuleBindings.module_installation_id), + eq(moduleVersions.id, appModuleBindings.module_version_id), + )).where(and( + eq(appModuleBindings.org_id, orgB), + eq(appModuleBindings.app_installation_id, stagedB.id), + eq(appModuleBindings.app_version_id, versionB!.id), + )); + const [contactBindingB] = await db.select({ binding: appModuleBindings, version: moduleVersions }) + .from(appModuleBindings).innerJoin(moduleVersions, and( + eq(moduleVersions.org_id, appModuleBindings.org_id), + eq(moduleVersions.installation_id, appModuleBindings.module_installation_id), + eq(moduleVersions.id, appModuleBindings.module_version_id), + )).where(and( + eq(appModuleBindings.org_id, orgB), eq(appModuleBindings.app_installation_id, dependencyB.id), + )); + const contactB = await createModuleRecord(actorB, { + module_id: 'org.deft.reference.resource-contacts', collection_key: 'contacts', + data: { name: 'Capacity contact', email: 'capacity@example.test' }, relations: {}, + expected_manifest_digest: contactBindingB!.version.manifest_digest, + idempotency_key: `capacity-contact-${randomUUID()}`, + }); + const campaignB = await createModuleRecord(actorB, { + module_id: 'org.deft.reference.resource-campaigns', collection_key: 'campaigns', + data: { name: 'Capacity campaign', subject: 'Capacity', body: 'Bounded.', status: 'ready' }, relations: {}, + expected_manifest_digest: campaignBindingB!.version.manifest_digest, + idempotency_key: `capacity-campaign-${randomUUID()}`, + }); + const placementB = moduleRef(campaignBindingB!.binding.module_installation_id, 'campaigns', campaignB.record!.id); + const selectedB = moduleRef(contactBindingB!.binding.module_installation_id, 'contacts', contactB.record!.id); + const relationB = await replaceResourceRelation(actorB, { + schema_version: RESOURCE_CONTRACT_VERSIONS.relation, source: placementB, relation_key: 'contacts', + refs: [selectedB], expected_revision: 0, idempotency_key: `capacity-relation-${randomUUID()}`, + }); + const [actionB] = await db.select().from(appActionBindings).where(and( + eq(appActionBindings.org_id, orgB), eq(appActionBindings.app_installation_id, stagedB.id), + eq(appActionBindings.app_version_id, versionB!.id), eq(appActionBindings.action_key, 'send_campaign_email'), + )); + const createDefinitionB = async (minuteOffset: number, createdAt = approvedAt, validitySeconds = 86_400) => { + const scheduledAt = new Date(scheduleBase + minuteOffset * 60_000); + const input = { + app_installation_id: stagedB.id, app_version_id: versionB!.id, action_binding_id: actionB!.id, + automation_request_key: 'daily_campaign_send', + placement: { resource_ref: placementB, revision: String(campaignB.record!.revision), content_digest: digestAppGrantValue(campaignB.record!.data) }, + selected: { resource_ref: selectedB, revision: String(contactB.record!.revision), content_digest: digestAppGrantValue(contactB.record!.data) }, + local_time: scheduledAt.toISOString().slice(11, 16), timezone: 'UTC', validity_seconds: validitySeconds, + max_org_runs_per_utc_day: 100, max_pending_org_fires: 25, + } as const; + const reviewed = await prepareAppAutomationDefinitionReview(actorB, input); + return createReviewedAppAutomationDefinition(actorB, { + ...input, expected_review_digest: reviewed.review_digest, accept_code_owned_policy: true, + }, { now: () => createdAt }); + }; + const activeB = await Promise.all([ + ...Array.from({ length: 24 }, (_value, index) => createDefinitionB(1, new Date(approvedAt.getTime() + index + 1))), + createDefinitionB(1, new Date(approvedAt.getTime() - 29 * 86_400_000), 30 * 86_400), + ...Array.from({ length: 76 }, (_value, index) => createDefinitionB(600, new Date(approvedAt.getTime() + 100 + index))), + ]); + const pausedB = await Promise.all(Array.from({ length: 100 }, async (_value, index) => { + const item = await createDefinitionB(10 + index, new Date(approvedAt.getTime() + index + 1)); + return pauseAppAutomationDefinition(actorB, { definition_id: item.id, expected_epoch: item.definition_epoch }); + })); + assert.equal(activeB.length, 101); + assert.equal(pausedB.length, 100); + + const querySamples: number[] = []; + let capacityCursor: string | undefined; + let managedCount = 0; + do { + const started = performance.now(); + const page = await listManagedAppAutomations(actorB, stagedB.id, { cursor: capacityCursor, limit: 50 }); + querySamples.push(performance.now() - started); + managedCount += page.definitions.length; + capacityCursor = page.next_cursor ?? undefined; + } while (capacityCursor); + assert.equal(managedCount, 201); + const managementP95 = [...querySamples].sort((a, b) => a - b)[ + Math.max(Math.ceil(querySamples.length * 0.95) - 1, 0) + ]!; + console.log('PREVIEW_MANAGEMENT_QUERY_RESULT', JSON.stringify({ + definitions: managedCount, + pages: querySamples.length, + p95_ms: managementP95, + })); + const scanStarted = performance.now(); + const scanAt = new Date(scheduleBase + 2 * 60_000); + await runAppAutomationScan(scanAt); + const scanMs = performance.now() - scanStarted; + const p95 = managementP95; + assert.equal((await db.select({ value: count() }).from(appAutomationDefinitions).where( + eq(appAutomationDefinitions.state, 'active'), + ))[0]?.value, 202); + assert.ok(p95 <= 250, `management query p95 ${p95}ms exceeds 250ms`); + assert.ok(scanMs <= 45_000, `full scan ${scanMs}ms exceeds 45s`); + let orgBJob: Awaited> = null; + for (let index = 0; index < 100; index += 1) { + const candidate = await dequeueJob(QUEUE_NAMES.SCHEDULED_JOBS, { lockedBy: 'capacity-proof' }); + if (!candidate) break; + if (candidate.name === 'app-automation-fire' && candidate.data.organization_id === orgB) { + orgBJob = candidate; + break; + } + await completeJob(candidate.id, candidate.lockToken); + } + assert.ok(orgBJob, 'Org B due fire is durably enqueued'); + assert.ok(scanMs <= 15_000, `Org B fire persistence/enqueue ${scanMs}ms exceeds 15s`); + console.log('PREVIEW_CAPACITY_RESULT', JSON.stringify({ + total: 402, + active: 202, + management_query_p95_ms: p95, + full_scan_ms: scanMs, + org_b_persist_enqueue_ms_max: scanMs, + org_b_fire_id: orgBJob.data.fire_id, + })); + return; + } + const [fire, duplicateFire] = await Promise.all([ persistFire(primary), persistFire(primary), @@ -841,8 +1077,17 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', assert.equal(duplicateFire.id, fire.id); const claimAt = new Date(primary.scheduledAt.getTime() + 60_000); const leaseExpiresAt = new Date(primary.scheduledAt.getTime() + 10 * 60_000); - const keys = await databaseCompleteAppRunTestKeyrings('loop5-lifecycle'); - t.after(() => keys.destroy()); + await shutdownAppRunRuntime(); + const keyringFixture = await databaseCompleteAppRunTestKeyringFixture('loop5-lifecycle'); + const keys = keyringFixture.keys; + const initialKeyringEnvironment = process.env.DEFT_APP_RUN_KEYRINGS; + process.env.DEFT_APP_RUN_KEYRINGS = keyringFixture.environment; + t.after(async () => { + await shutdownAppRunRuntime(); + keys.destroy(); + if (initialKeyringEnvironment === undefined) delete process.env.DEFT_APP_RUN_KEYRINGS; + else process.env.DEFT_APP_RUN_KEYRINGS = initialKeyringEnvironment; + }); const secrets = new AppRunSecretService(keys); const preparedInputs = new AppRunPreparedInputService(secrets); const repository = new PostgresAppRunRepository(); @@ -954,6 +1199,131 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', eq(appRuns.org_id, orgId), ))[0]?.value, runCountBefore + 1); + // Production path: actual discovery/review above pins this stdio provider; + // the scanner owns the fire ledger, the durable queues own delivery, and + // the generic App Run handler owns the only provider call. + const scannerCase = await createDefinition(10); + const scannerNow = new Date(scannerCase.scheduledAt.getTime() + 60_000); + await runAppAutomationScan(scannerNow); + await runAppAutomationScan(scannerNow); + const fireJob = await dequeueJob(QUEUE_NAMES.SCHEDULED_JOBS, { + lockedBy: 'track-a-due-flow', + orgId, + jobName: 'app-automation-fire', + dataMatch: { key: 'definition_id', value: scannerCase.definition.id }, + }); + assert.ok(fireJob); + assert.equal(fireJob.name, 'app-automation-fire'); + assert.equal(fireJob.data.definition_id, scannerCase.definition.id); + await runAppAutomationFire({ + id: fireJob.id, + name: fireJob.name, + data: fireJob.data, + attempts: fireJob.attempts, + leaseExpiresAt: fireJob.lockExpiresAt, + }, scannerNow); + await runAppAutomationFire({ + id: `${fireJob.id}:duplicate`, + name: fireJob.name, + data: fireJob.data, + attempts: fireJob.attempts + 1, + leaseExpiresAt: fireJob.lockExpiresAt, + }, scannerNow); + const scannedFire = await postgresAppAutomationVerificationReadPort.load({ + organization_id: orgId, + definition_id: scannerCase.definition.id, + fire_id: String(fireJob.data.fire_id), + }); + assert.equal(scannedFire?.fire.state, 'run_created'); + assert.ok(scannedFire?.fire.app_run_id); + let attemptJob = await dequeueJob(QUEUE_NAMES.AGENT_JOBS, { + lockedBy: 'track-a-due-attempt', + orgId, + jobName: 'app-run-attempt', + dataMatch: { key: 'runId', value: scannedFire!.fire.app_run_id! }, + }); + if (!attemptJob) { + const runtime = await getAppRunRuntime(); + const preparedAttempt = await runtime.attemptRunner.prepareAttempt(orgId, scannedFire!.fire.app_run_id!); + assert.ok(preparedAttempt, 'production runtime can schedule the released automation Run'); + attemptJob = await dequeueJob(QUEUE_NAMES.AGENT_JOBS, { + lockedBy: 'track-a-due-attempt-rearm', + orgId, + jobName: 'app-run-attempt', + dataMatch: { key: 'runId', value: scannedFire!.fire.app_run_id! }, + }); + } + assert.ok(attemptJob); + assert.equal(attemptJob.name, 'app-run-attempt'); + await handleAppRunAttempt({ + id: attemptJob.id, + name: attemptJob.name, + data: attemptJob.data, + attempts: attemptJob.attempts, + leaseExpiresAt: attemptJob.lockExpiresAt, + }); + await handleAppRunAttempt({ + id: `${attemptJob.id}:duplicate`, + name: attemptJob.name, + data: attemptJob.data, + attempts: attemptJob.attempts + 1, + leaseExpiresAt: attemptJob.lockExpiresAt, + }); + const effects = (await readFile(providerOutbox, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); + assert.equal(effects.length, 1, 'duplicate scanner and worker delivery creates one durable provider effect'); + assert.equal((await db.select({ value: count() }).from(appRunReceipts).where(and( + eq(appRunReceipts.org_id, orgId), + eq(appRunReceipts.run_id, scannedFire!.fire.app_run_id!), + )))[0]?.value, 1, 'generic worker writes the durable receipt'); + + const revokedCase = await createDefinition(11); + const revokedNow = new Date(revokedCase.scheduledAt.getTime() + 60_000); + await runAppAutomationScan(revokedNow); + const revokedFireJob = await dequeueJob(QUEUE_NAMES.SCHEDULED_JOBS, { + lockedBy: 'track-a-revoked-fire', + orgId, + jobName: 'app-automation-fire', + dataMatch: { key: 'definition_id', value: revokedCase.definition.id }, + }); + assert.ok(revokedFireJob); + await runAppAutomationFire({ + id: revokedFireJob.id, + name: revokedFireJob.name, + data: revokedFireJob.data, + attempts: revokedFireJob.attempts, + leaseExpiresAt: revokedFireJob.lockExpiresAt, + }, revokedNow); + const revokedFire = await postgresAppAutomationVerificationReadPort.load({ + organization_id: orgId, + definition_id: revokedCase.definition.id, + fire_id: String(revokedFireJob.data.fire_id), + }); + assert.ok(revokedFire?.fire.app_run_id); + const revokedAttemptJob = await dequeueJob(QUEUE_NAMES.AGENT_JOBS, { + lockedBy: 'track-a-revoked-attempt', + orgId, + jobName: 'app-run-attempt', + dataMatch: { key: 'runId', value: revokedFire!.fire.app_run_id! }, + }); + assert.ok(revokedAttemptJob); + await revokeAppAutomationDefinition(actor, { + definition_id: revokedCase.definition.id, + expected_epoch: revokedCase.definition.definition_epoch, + }); + await handleAppRunAttempt({ + id: revokedAttemptJob.id, + name: revokedAttemptJob.name, + data: revokedAttemptJob.data, + attempts: revokedAttemptJob.attempts, + leaseExpiresAt: revokedAttemptJob.lockExpiresAt, + }); + assert.equal((await readFile(providerOutbox, 'utf8')).trim().split('\n').length, 1, + 'revocation before delivery creates no provider effect'); + assert.equal((await db.select({ value: count() }).from(appRunReceipts).where(and( + eq(appRunReceipts.org_id, orgId), + eq(appRunReceipts.run_id, revokedFire!.fire.app_run_id!), + )))[0]?.value, 0, 'revocation before delivery writes no receipt'); + const budgetLimited = await createDefinition(1, 1); const budgetFire = await persistFire(budgetLimited); assert.equal(await db.transaction((tx) => claimAppAutomationFireWithExecutor(tx, { @@ -1936,6 +2306,39 @@ test('reviewed v0-to-v1 upgrade atomically preserves App pointers, Module data, assert.deepEqual(retentionRefusal.details, { cascaded: false, data_preserved: true }); assert.deepEqual(await snapshotGraph(), graphBeforeUninstallRefusals); + const upgradeRouteApp = new Hono(); + upgradeRouteApp.use('*', async (context, next) => { + context.set('user', { + id: userId, + org_id: orgId, + email: 'connected-upgrade@example.test', + name: 'Connected upgrade owner', + role: 'owner', + }); + await next(); + }); + upgradeRouteApp.route('/api/apps', appRoutes); + + const emptyUninstallResponse = await upgradeRouteApp.request(`/api/apps/${predecessor.id}/uninstall`, { + method: 'POST', + }); + assert.equal(emptyUninstallResponse.status, 400); + assert.equal((await emptyUninstallResponse.json()).code, 'VALIDATION_ERROR'); + assert.deepEqual(await snapshotGraph(), graphBeforeUninstallRefusals); + + const retentionResponse = await upgradeRouteApp.request(`/api/apps/${predecessor.id}/uninstall`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ expected_lifecycle_epoch: afterUpgrade!.lifecycle_epoch }), + }); + assert.equal(retentionResponse.status, 409); + assert.deepEqual(await retentionResponse.json(), { + error: 'App uninstall requires an explicit export and retention decision', + code: 'APP_UNINSTALL_REQUIRES_RETENTION_DECISION', + details: { cascaded: false, data_preserved: true }, + }); + assert.deepEqual(await snapshotGraph(), graphBeforeUninstallRefusals); + const identicalBuilt = await buildPhase5ConnectedAppPackage({ app_version: '3.0.2' }); const identical = await stageAppUpgrade( actor, diff --git a/apps/api/test/apps-uninstall-route.test.ts b/apps/api/test/apps-uninstall-route.test.ts new file mode 100644 index 00000000..9d847f8b --- /dev/null +++ b/apps/api/test/apps-uninstall-route.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Hono } from 'hono'; +import { appRoutes } from '../src/routes/apps.js'; + +test('empty uninstall request returns the standard validation error', async () => { + const app = new Hono(); + app.use('*', async (context, next) => { + context.set('user', { + id: 'uninstall-route-owner', + org_id: 'uninstall-route-org', + email: 'uninstall-route@example.test', + name: 'Uninstall route owner', + role: 'owner', + }); + await next(); + }); + app.route('/api/apps', appRoutes); + + const response = await app.request('/api/apps/fixture-installation/uninstall', { method: 'POST' }); + + assert.equal(response.status, 400); + const body = await response.json(); + assert.equal(body.error, 'Invalid App request'); + assert.equal(body.code, 'VALIDATION_ERROR'); +}); diff --git a/apps/api/test/auth-refresh-contract.test.ts b/apps/api/test/auth-refresh-contract.test.ts index eb76b31f..eb6dbad5 100644 --- a/apps/api/test/auth-refresh-contract.test.ts +++ b/apps/api/test/auth-refresh-contract.test.ts @@ -3,25 +3,25 @@ * * Purpose: lock down the server-side refresh endpoint contract so that future * refactors cannot silently break the web client's 401-retry logic that now - * relies on it. The server is NOT being changed here — we are merely asserting - * its current behaviour. + * relies on it. Browser credentials use durable one-use session families. * * Covers: * 1. Valid refresh token → 200 + rotated accessToken + refreshToken - * 2. Revoked refresh token → 401 + code: TOKEN_REVOKED + * 2. Revoked refresh token → 401 + code: INVALID_TOKEN * 3. Malformed / garbage token → 401 + code: INVALID_TOKEN - * 4. Missing refresh token → 401 + code: NO_TOKEN + * 4. Missing refresh token → 400 + code: VALIDATION_ERROR * * Run: pnpm --filter @deft/api test -- auth-refresh-contract */ import { test, describe, before, after } from 'node:test'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; -import { createHash } from 'node:crypto'; import pg from 'pg'; import jwt from 'jsonwebtoken'; import { Hono } from 'hono'; import { authRoutes } from '../src/routes/auth.js'; +import { createWebSession, revokeWebSession } from '../src/lib/web-sessions.js'; +import { env } from '../src/lib/env.js'; // ── Database helpers ────────────────────────────────────────────────────────── @@ -55,8 +55,7 @@ async function callRefresh(body: unknown): Promise<{ status: number; json: unkno // ── Fixtures ────────────────────────────────────────────────────────────────── -const JWT_REFRESH_SECRET = - process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-me'; +const JWT_REFRESH_SECRET = env.JWT_REFRESH_SECRET; // Unique ids per run so parallel test suites don't collide. const TEST_USER_ID = `auth-refresh-contract-user-${crypto.randomUUID()}`; @@ -93,21 +92,13 @@ before(async () => { }); // Generate a valid refresh token via the same algorithm the server uses. - validRefreshToken = jwt.sign( + validRefreshToken = (await createWebSession( { id: TEST_USER_ID, email: `contract-test-${TEST_USER_ID.slice(-8)}@test.local`, org_id: TEST_ORG_ID }, - JWT_REFRESH_SECRET, - { expiresIn: '30d' }, - ); + )).refreshToken; }); after(async () => { await withClient(async (c) => { - // Clean up revoked tokens first (FK constraint) — guard against seeding failure - if (validRefreshToken) { - const tokenHash = createHash('sha256').update(validRefreshToken).digest('hex'); - await c.query(`DELETE FROM revoked_tokens WHERE token_hash = $1`, [tokenHash]); - } - await c.query(`DELETE FROM org_members WHERE user_id = $1`, [TEST_USER_ID]); await c.query(`DELETE FROM users WHERE id = $1`, [TEST_USER_ID]); await c.query(`DELETE FROM orgs WHERE id = $1`, [TEST_ORG_ID]); @@ -135,27 +126,12 @@ describe('/api/auth/refresh contract', () => { assert.equal(decoded.id, TEST_USER_ID, 'rotated refreshToken should carry the correct user id'); }); - test('2. revoked refresh token → 401 with code TOKEN_REVOKED', async () => { - // Revoke the token by inserting its hash into revoked_tokens (same logic as /logout). - const tokenHash = createHash('sha256').update(validRefreshToken).digest('hex'); - await withClient(async (c) => { - await c.query( - `INSERT INTO revoked_tokens (id, token_hash) VALUES ($1, $2) ON CONFLICT DO NOTHING`, - [crypto.randomUUID(), tokenHash], - ); - }); - - try { - const { status, json } = await callRefresh({ refreshToken: validRefreshToken }); - assert.equal(status, 401, `expected 401 for revoked token, got ${status}`); - const body = json as Record; - assert.equal(body.code, 'TOKEN_REVOKED', `expected code TOKEN_REVOKED, got ${body.code}`); - } finally { - // Remove the revocation so other tests (e.g. test 1 if run order changes) aren't affected. - await withClient(async (c) => { - await c.query(`DELETE FROM revoked_tokens WHERE token_hash = $1`, [tokenHash]); - }); - } + test('2. revoked refresh token → 401 with code INVALID_TOKEN', async () => { + const pair = await createWebSession({ id: TEST_USER_ID, email: `${TEST_USER_ID}@test.local`, org_id: TEST_ORG_ID }); + await revokeWebSession(pair.refreshToken); + const { status, json } = await callRefresh({ refreshToken: pair.refreshToken }); + assert.equal(status, 401, `expected 401 for revoked token, got ${status}`); + assert.equal((json as Record).code, 'INVALID_TOKEN'); }); test('3. malformed / garbage token → 401 with code INVALID_TOKEN', async () => { @@ -168,10 +144,10 @@ describe('/api/auth/refresh contract', () => { ); }); - test('4. missing refresh token → 401 with code NO_TOKEN', async () => { + test('4. missing refresh token → 400 with code VALIDATION_ERROR', async () => { const { status, json } = await callRefresh({}); - assert.equal(status, 401, `expected 401 when no token provided, got ${status}`); + assert.equal(status, 400, `expected 400 when no token provided, got ${status}`); const body = json as Record; - assert.equal(body.code, 'NO_TOKEN', `expected NO_TOKEN code, got ${body.code}`); + assert.equal(body.code, 'VALIDATION_ERROR', `expected VALIDATION_ERROR code, got ${body.code}`); }); }); diff --git a/apps/api/test/fixtures/app-run-enabled-env.ts b/apps/api/test/fixtures/app-run-enabled-env.ts new file mode 100644 index 00000000..ae6f677c --- /dev/null +++ b/apps/api/test/fixtures/app-run-enabled-env.ts @@ -0,0 +1,20 @@ +// Import first in the isolated lifecycle test process, before env.ts freezes flags. +// These deterministic keys are disposable test data; the lifecycle replaces them +// with the database-complete fixture before composing the lazy runtime. +import { createHash } from 'node:crypto'; +import { APP_RUN_CONTRACT_VERSIONS } from '@deft/shared'; + +function key(purpose: string): string { + return createHash('sha256').update(`loop5-lifecycle:${purpose}:${purpose === 'run_encryption' ? 'enc-v1' : purpose === 'receipt_signing' ? 'sig-v1' : 'fp-v1'}`).digest('base64'); +} + +process.env.DEFT_APPS_ENABLED = 'true'; +process.env.DEFT_APP_RUNS_ENABLED = 'true'; +process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; +process.env.DEFT_APP_AUTOMATIONS_ENABLED = 'true'; +process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ + schema_version: APP_RUN_CONTRACT_VERSIONS.keyring, + run_encryption: { current: 'enc-v1', keys: { 'enc-v1': key('run_encryption') } }, + receipt_signing: { current: 'sig-v1', keys: { 'sig-v1': key('receipt_signing') } }, + fingerprint: { current: 'fp-v1', keys: { 'fp-v1': key('fingerprint') } }, +}); diff --git a/apps/api/test/fixtures/app-run-test-keyrings.ts b/apps/api/test/fixtures/app-run-test-keyrings.ts index faa3dcb3..cb35a790 100644 --- a/apps/api/test/fixtures/app-run-test-keyrings.ts +++ b/apps/api/test/fixtures/app-run-test-keyrings.ts @@ -25,7 +25,7 @@ function keyMap( * the shared disposable database. This satisfies the global retirement guard * without making focused tests depend on file order or another test's org. */ -export async function databaseCompleteAppRunTestKeyrings(seed: string) { +export async function databaseCompleteAppRunTestKeyringFixture(seed: string) { const [fingerprintRows, encryptionRows, signingRows] = await Promise.all([ db.select({ idempotency: appRuns.idempotency_key_version, @@ -40,7 +40,7 @@ export async function databaseCompleteAppRunTestKeyrings(seed: string) { ]); const encryptionKeyIds = new Set(['enc-v1', ...encryptionRows.map((row) => row.keyId)]); const signingKeyIds = new Set(['sig-v1', ...signingRows.map((row) => row.keyId)]); - return parseEnvironmentAppRunKeyrings(JSON.stringify({ + const environment = JSON.stringify({ schema_version: APP_RUN_CONTRACT_VERSIONS.keyring, run_encryption: { current: 'enc-v1', @@ -54,5 +54,10 @@ export async function databaseCompleteAppRunTestKeyrings(seed: string) { current: 'fp-v1', keys: keyMap(seed, 'fingerprint', fingerprintKeyIds), }, - })); + }); + return { environment, keys: parseEnvironmentAppRunKeyrings(environment) }; +} + +export async function databaseCompleteAppRunTestKeyrings(seed: string) { + return (await databaseCompleteAppRunTestKeyringFixture(seed)).keys; } diff --git a/apps/api/test/identity-hardening.test.ts b/apps/api/test/identity-hardening.test.ts index 9a8b8dd3..5e65a5f9 100644 --- a/apps/api/test/identity-hardening.test.ts +++ b/apps/api/test/identity-hardening.test.ts @@ -14,6 +14,7 @@ import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import pg from 'pg'; import jwt from 'jsonwebtoken'; +import bcrypt from 'bcryptjs'; import { Hono } from 'hono'; import { authRoutes } from '../src/routes/auth.js'; import { authMiddleware } from '../src/middleware/auth.js'; @@ -22,6 +23,7 @@ import { apiKeyRoutes } from '../src/routes/api-keys.js'; import { memberRoutes } from '../src/routes/members.js'; import { taskRoutes } from '../src/routes/tasks.js'; import { inviteRoutes } from '../src/routes/invites.js'; +import { createWebSession } from '../src/lib/web-sessions.js'; import { env } from '../src/lib/env.js'; const DATABASE_URL = @@ -68,13 +70,15 @@ const TARGET_OAUTH_GRANT_ID = crypto.randomUUID(); const TARGET_OAUTH_ACCESS_ID = crypto.randomUUID(); const TARGET_OAUTH_REFRESH_ID = crypto.randomUUID(); const INVITED_EMAIL = `invite-${RUN_ID}@test.local`; +const RACE_EMAIL = `invite-race-${RUN_ID}@test.local`; -function accessToken(userId: string, orgId = ORG_ID, email = `${userId}@test.local`) { - return jwt.sign({ id: userId, email, org_id: orgId }, env.JWT_SECRET, { expiresIn: '15m' }); +const sessions = new Map>>(); +const sessionKey = (userId: string, orgId: string) => `${orgId}:${userId}`; +function accessToken(userId: string, orgId = ORG_ID) { + return sessions.get(sessionKey(userId, orgId))!.accessToken; } - -function refreshToken(userId: string, orgId = ORG_ID, email = `${userId}@test.local`) { - return jwt.sign({ id: userId, email, org_id: orgId }, env.JWT_REFRESH_SECRET, { expiresIn: '30d' }); +function refreshToken(userId: string, orgId = ORG_ID) { + return sessions.get(sessionKey(userId, orgId))!.refreshToken; } async function authed(path: string, userId: string, init: RequestInit = {}) { @@ -211,11 +215,27 @@ before(async () => { [OTHER_GROUP_MEMBER_ID, OTHER_GROUP_ID, OTHER_USER_ID], ); }); + const identities = [ + [ADMIN_ID, ORG_ID, `admin-${RUN_ID}@test.local`], + [MEMBER_ID, ORG_ID, `member-${RUN_ID}@test.local`], + [TARGET_ID, ORG_ID, `target-${RUN_ID}@test.local`], + [OTHER_USER_ID, OTHER_ORG_ID, `other-${RUN_ID}@test.local`], + ] as const; + for (const [id, orgId, email] of identities) { + sessions.set(sessionKey(id, orgId), await createWebSession({ id, org_id: orgId, email })); + } + await withClient(c => c.query(`UPDATE org_members SET is_active = true WHERE org_id = $1 AND user_id = $2`, [ORG_ID, INACTIVE_ID]).then(() => undefined)); + sessions.set(sessionKey(INACTIVE_ID, ORG_ID), await createWebSession({ id: INACTIVE_ID, org_id: ORG_ID, email: `inactive-${RUN_ID}@test.local` })); + await withClient(c => c.query(`UPDATE org_members SET is_active = false WHERE org_id = $1 AND user_id = $2`, [ORG_ID, INACTIVE_ID]).then(() => undefined)); }); after(async () => { await withClient(async (c) => { + await c.query(`DELETE FROM web_sessions WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); await c.query(`DELETE FROM invites WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); + await c.query(`DELETE FROM space_members WHERE space_id IN (SELECT id FROM spaces WHERE created_by IN (SELECT id FROM users WHERE email = ANY($1::text[])))`, [[INVITED_EMAIL, RACE_EMAIL]]); + await c.query(`DELETE FROM spaces WHERE created_by IN (SELECT id FROM users WHERE email = ANY($1::text[]))`, [[INVITED_EMAIL, RACE_EMAIL]]); + await c.query(`DELETE FROM space_members WHERE user_id IN (SELECT id FROM users WHERE email = ANY($1::text[]))`, [[INVITED_EMAIL, RACE_EMAIL]]); await c.query(`DELETE FROM oauth_access_tokens WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); await c.query(`DELETE FROM oauth_refresh_tokens WHERE grant_id IN (SELECT id FROM oauth_grants WHERE org_id IN ($1, $2))`, [ORG_ID, OTHER_ORG_ID]); await c.query(`DELETE FROM oauth_grants WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); @@ -231,7 +251,7 @@ after(async () => { await c.query(`DELETE FROM space_members WHERE space_id = $1`, [SPACE_ID]); await c.query(`DELETE FROM spaces WHERE id = $1`, [SPACE_ID]); await c.query(`DELETE FROM org_members WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); - await c.query(`DELETE FROM users WHERE email = $1`, [INVITED_EMAIL]); + await c.query(`DELETE FROM users WHERE email = ANY($1::text[])`, [[INVITED_EMAIL, RACE_EMAIL]]); await c.query(`DELETE FROM users WHERE id = ANY($1::text[])`, [[ADMIN_ID, MEMBER_ID, TARGET_ID, INACTIVE_ID, OTHER_USER_ID]]); await c.query(`DELETE FROM orgs WHERE id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); }); @@ -326,10 +346,12 @@ describe('Loop 0 identity hardening', () => { }); test('member invite creates a durable invite row used by preview', async () => { - const res = await authed('/api/members/invite', ADMIN_ID, { + const adminSession = await createWebSession({ id: ADMIN_ID, org_id: ORG_ID, email: `admin-${RUN_ID}@test.local` }); + const res = await app.fetch(new Request('http://localhost/api/members/invite', { method: 'POST', + headers: { Authorization: `Bearer ${adminSession.accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ email: INVITED_EMAIL, role: 'member' }), - }); + })); const body = await res.json() as { invite_url: string; expires_at: string }; assert.equal(res.status, 201, JSON.stringify(body)); const token = new URL(body.invite_url).pathname.split('/').pop(); @@ -348,6 +370,79 @@ describe('Loop 0 identity hardening', () => { assert.equal(preview.status, 200, JSON.stringify(previewBody)); assert.equal(previewBody.email, INVITED_EMAIL); assert.equal(previewBody.already_accepted, false); + + const passwords = ['Invite-race-alpha-2026!', 'Invite-race-beta-2026!']; + const responses = await Promise.all(passwords.map((password, index) => app.fetch(new Request('http://localhost/api/invites/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, password, name: `Invite winner ${index}` }), + })))); + const statuses = responses.map(response => response.status).sort(); + assert.deepEqual(statuses, [200, 400]); + const winnerIndex = responses.findIndex(response => response.status === 200); + assert.notEqual(winnerIndex, -1); + assert.equal((await responses[1 - winnerIndex]!.json() as Record).code, 'INVITE_ALREADY_ACCEPTED'); + + await withClient(async (c) => { + const { rows } = await c.query( + `SELECT u.password_hash, u.password_version, + (SELECT count(*)::int FROM web_sessions ws WHERE ws.user_id = u.id) AS session_count, + (SELECT count(*)::int FROM invites i WHERE i.org_id = $1 AND i.email = $2 AND i.accepted_at IS NOT NULL) AS accepted_count + FROM users u WHERE u.email = $2`, + [ORG_ID, INVITED_EMAIL], + ); + assert.equal(rows.length, 1); + assert.equal(rows[0].password_version, 1); + assert.equal(rows[0].session_count, 1); + assert.equal(rows[0].accepted_count, 1); + assert.equal(await bcrypt.compare(passwords[winnerIndex]!, rows[0].password_hash), true); + assert.equal(await bcrypt.compare(passwords[1 - winnerIndex]!, rows[0].password_hash), false); + }); + + const raceInviteResponse = await app.fetch(new Request('http://localhost/api/members/invite', { + method: 'POST', headers: { Authorization: `Bearer ${adminSession.accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: RACE_EMAIL, role: 'member' }), + })); + const raceInviteBody = await raceInviteResponse.json() as { invite_url: string; error?: string }; + assert.equal(raceInviteResponse.status, 201, JSON.stringify(raceInviteBody)); + const raceToken = new URL(raceInviteBody.invite_url).pathname.split('/').pop()!; + const raceInvite = await withClient(async (c) => { + const { rows } = await c.query(`SELECT id FROM invites WHERE token = $1`, [raceToken]); + return rows[0] as { id: string }; + }); + const [acceptRace, revokeRace] = await Promise.all([ + app.fetch(new Request('http://localhost/api/invites/accept', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: raceToken, password: 'Invite-revoke-race-2026!' }), + })), + app.fetch(new Request(`http://localhost/api/members/invites/${raceInvite.id}`, { + method: 'DELETE', headers: { Authorization: `Bearer ${adminSession.accessToken}` }, + })), + ]); + assert.equal( + (acceptRace.status === 200 && revokeRace.status === 409) + || (acceptRace.status === 400 && revokeRace.status === 200), + true, + `unexpected accept/revoke results ${acceptRace.status}/${revokeRace.status}`, + ); + await withClient(async (c) => { + const { rows } = await c.query( + `SELECT u.password_hash, om.is_active, + (SELECT count(*)::int FROM web_sessions ws WHERE ws.user_id = u.id AND ws.revoked_at IS NULL) session_count + FROM users u JOIN org_members om ON om.user_id = u.id AND om.org_id = $1 WHERE u.email = $2`, + [ORG_ID, RACE_EMAIL], + ); + assert.equal(rows.length, 1); + if (acceptRace.status === 200) { + assert.equal(rows[0].is_active, true); + assert.ok(rows[0].password_hash); + assert.equal(rows[0].session_count, 1); + } else { + assert.equal(rows[0].is_active, false); + assert.equal(rows[0].password_hash, null); + assert.equal(rows[0].session_count, 0); + } + }); }); test('task writes reject inactive or cross-org assignees', async () => { @@ -405,6 +500,32 @@ describe('Loop 0 identity hardening', () => { }); test('member removal revokes space access and personal MCP tokens', async () => { + const triggerSuffix = RUN_ID.replace(/-/g, '_'); + const triggerName = `fail_web_session_revoke_${triggerSuffix}`; + const functionName = `fail_web_session_revoke_fn_${triggerSuffix}`; + const before = await withClient(async (c) => { + const { rows } = await c.query(`SELECT password_version FROM users WHERE id = $1`, [TARGET_ID]); + await c.query(`CREATE FUNCTION ${functionName}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'injected revoke failure'; END $$`); + await c.query(`CREATE TRIGGER ${triggerName} BEFORE UPDATE ON web_sessions FOR EACH ROW WHEN (OLD.user_id = '${TARGET_ID}' AND NEW.revoked_at IS NOT NULL) EXECUTE FUNCTION ${functionName}()`); + return rows[0] as { password_version: number }; + }); + const rollbackResponse = await authed(`/api/members/${TARGET_ID}`, ADMIN_ID, { method: 'DELETE' }); + assert.equal(rollbackResponse.status, 500); + await withClient(async (c) => { + await c.query(`DROP TRIGGER ${triggerName} ON web_sessions`); + await c.query(`DROP FUNCTION ${functionName}()`); + const { rows } = await c.query( + `SELECT om.is_active, u.password_version FROM users u JOIN org_members om ON om.user_id = u.id WHERE u.id = $1 AND om.org_id = $2`, + [TARGET_ID, ORG_ID], + ); + assert.equal(rows[0].is_active, true, 'membership deactivation must roll back with session revocation'); + assert.equal(rows[0].password_version, before.password_version, 'password version must roll back with session revocation'); + }); + + const oldAccessToken = accessToken(TARGET_ID); + const oldResetToken = jwt.sign({ + id: TARGET_ID, org_id: ORG_ID, purpose: 'password-reset', password_version: before.password_version, + }, env.JWT_SECRET, { algorithm: 'HS256', expiresIn: '24h' }); const res = await authed(`/api/members/${TARGET_ID}`, ADMIN_ID, { method: 'DELETE' }); assert.equal(res.status, 200, await res.text()); @@ -450,6 +571,17 @@ describe('Loop 0 identity hardening', () => { [TARGET_OAUTH_REFRESH_ID], ); assert.equal(oauthRefreshRows.rows[0].revoked, true); + const version = await c.query(`SELECT password_version FROM users WHERE id = $1`, [TARGET_ID]); + assert.equal(version.rows[0].password_version, before.password_version + 1); }); + + await withClient(c => c.query(`UPDATE org_members SET is_active = true WHERE org_id = $1 AND user_id = $2`, [ORG_ID, TARGET_ID]).then(() => undefined)); + const revivedAccess = await app.fetch(new Request('http://localhost/api/groups', { headers: { Authorization: `Bearer ${oldAccessToken}` } })); + assert.equal(revivedAccess.status, 401, 'reactivation must not revive a pre-removal session'); + const staleReset = await app.fetch(new Request('http://localhost/api/auth/reset-password', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: oldResetToken, password: 'stale-reset-must-fail-2026' }), + })); + assert.equal(staleReset.status, 400, 'reactivation must not revive a pre-removal recovery URL'); }); }); diff --git a/apps/api/test/job-queue-reliability.test.ts b/apps/api/test/job-queue-reliability.test.ts index c491dcfa..908f1c2b 100644 --- a/apps/api/test/job-queue-reliability.test.ts +++ b/apps/api/test/job-queue-reliability.test.ts @@ -66,6 +66,86 @@ test('concurrent dequeue claims a job once with a unique ownership token', async assert.equal(await completeJob(claimed[0]!.id, claimed[0]!.lockToken), true); }); +test('exact dequeue filters claim one matching job without consuming older unrelated work', async (t) => { + const orgId = crypto.randomUUID(); + const otherOrgId = crypto.randomUUID(); + const name = `filtered-claim:${crypto.randomUUID()}`; + const otherName = `${name}:other-name`; + t.after(async () => { + await db.delete(jobQueue).where(and( + eq(jobQueue.queue, TEST_QUEUE), + sql`${jobQueue.name} IN (${name}, ${otherName})`, + )); + }); + const marker = crypto.randomUUID(); + const unrelatedIds = Array.from({ length: 101 }, () => crypto.randomUUID()); + + await db.insert(jobQueue).values([ + ...unrelatedIds.map((id) => ({ + id, + org_id: otherOrgId, + queue: TEST_QUEUE, + name, + data: { marker }, + status: 'pending', + })), + { + id: crypto.randomUUID(), + org_id: orgId, + queue: TEST_QUEUE, + name: otherName, + data: { marker }, + status: 'pending', + }, + { + id: crypto.randomUUID(), + org_id: orgId, + queue: TEST_QUEUE, + name, + data: { marker: `${marker}:other-value` }, + status: 'pending', + }, + ]); + const targetId = crypto.randomUUID(); + await db.insert(jobQueue).values({ + id: targetId, + org_id: orgId, + queue: TEST_QUEUE, + name, + data: { marker }, + status: 'pending', + }); + + const claimed = await dequeueJob(TEST_QUEUE, { + lockedBy: 'filtered-owner', + leaseMs: 30_000, + orgId, + jobName: name, + dataMatch: { key: 'marker', value: marker }, + }); + assert.ok(claimed); + assert.equal(claimed.id, targetId); + assert.equal(claimed.lockedBy, 'filtered-owner'); + assert.match(claimed.lockToken, /^[0-9a-f-]{36}$/i); + assert.ok(claimed.lockExpiresAt.getTime() > Date.now()); + + const [persisted] = await db.select().from(jobQueue).where(eq(jobQueue.id, targetId)); + assert.equal(persisted?.status, 'running'); + assert.equal(persisted?.locked_by, claimed.lockedBy); + assert.equal(persisted?.lock_token, claimed.lockToken); + assert.equal(persisted?.lock_expires_at?.getTime(), claimed.lockExpiresAt.getTime()); + + const [unrelated] = await db.select({ count: sql`count(*)::int` }) + .from(jobQueue) + .where(and( + eq(jobQueue.queue, TEST_QUEUE), + sql`${jobQueue.id} <> ${targetId}`, + eq(jobQueue.status, 'pending'), + )); + assert.equal(Number(unrelated?.count), 103); + assert.equal(await completeJob(claimed.id, claimed.lockToken), true); +}); + test('expired and superseded tokens cannot settle a reclaimed job', async () => { const name = `token-fence:${crypto.randomUUID()}`; await enqueue(TEST_QUEUE, name, {}, { maxAttempts: 3 }); diff --git a/apps/api/test/native-create-replay.test.ts b/apps/api/test/native-create-replay.test.ts new file mode 100644 index 00000000..198ca437 --- /dev/null +++ b/apps/api/test/native-create-replay.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { before, after, test } from 'node:test'; +import { Hono } from 'hono'; +import { and, eq, inArray, sql } from 'drizzle-orm'; +import { orgs, users, orgMembers, spaces, spaceMembers, projects, messages, tasks, events, taskActivity, files, messageAttachments, jobQueue, notifications, nativeCreateRequests } from '@deft/db/schema'; +import { db, closeDb } from '../src/lib/db.js'; + +const orgId = randomUUID(), otherOrg = randomUUID(), userId = randomUUID(), sameOrgUser = randomUUID(), otherUser = randomUUID(); +const spaceId = randomUUID(), projectId = randomUUID(); +let app: Hono; +before(async () => { + await db.insert(orgs).values([{ id: orgId, name: 'Replay test', slug: `replay-${orgId}` }, { id: otherOrg, name: 'Other', slug: `replay-${otherOrg}` }]); + await db.insert(users).values([{ id: userId, name: 'Owner', email: `${userId}@test.local` }, { id: sameOrgUser, name: 'Member', email: `${sameOrgUser}@test.local` }, { id: otherUser, name: 'Other', email: `${otherUser}@test.local` }]); + await db.insert(orgMembers).values([{ org_id: orgId, user_id: userId, role: 'owner' }, { org_id: orgId, user_id: sameOrgUser, role: 'member' }, { org_id: otherOrg, user_id: otherUser, role: 'owner' }]); + await db.insert(spaces).values({ id: spaceId, org_id: orgId, name: 'Replay', type: 'public', created_by: userId }); + await db.insert(spaceMembers).values([{ space_id: spaceId, user_id: userId }, { space_id: spaceId, user_id: sameOrgUser }]); + await db.insert(projects).values({ id: projectId, org_id: orgId, name: 'Replay', prefix: 'RPL', created_by: userId }); + const [{ messageRoutes }, { taskRoutes }, { projectRoutes }, { eventRoutes }] = await Promise.all([import('../src/routes/messages.js'), import('../src/routes/tasks.js'), import('../src/routes/projects.js'), import('../src/routes/events.js')]); + app = new Hono(); + app.use('*', async (c, next) => { + const other = c.req.header('x-test-other') === 'true'; + const sameOrgActor = c.req.header('x-test-same-org-actor') === 'true'; + c.set('user', { + id: other ? otherUser : sameOrgActor ? sameOrgUser : userId, + org_id: other ? otherOrg : orgId, + email: other ? 'other@test.local' : sameOrgActor ? 'member@test.local' : 'owner@test.local', + role: other ? 'owner' : sameOrgActor ? 'member' : 'owner', + }); + await next(); + }); + app.route('/messages', messageRoutes); app.route('/tasks', taskRoutes); app.route('/projects', projectRoutes); app.route('/events', eventRoutes); +}); + +async function create(route: string, body: unknown, key?: string, other = false, sameOrgActor = false) { + const headers: Record = { 'Content-Type': 'application/json', 'x-test-other': String(other), 'x-test-same-org-actor': String(sameOrgActor) }; + if (key !== undefined) headers['Idempotency-Key'] = key; + const response = await app.request(route, { method: 'POST', headers, body: JSON.stringify(body) }); + return { status: response.status, body: await response.json() as any }; +} + +test('native creates retain one identity under concurrent explicit replay without deduplicating new intents', async () => { + for (const [route, body] of [ + [`/messages/${spaceId}`, { content: 'Same content' }], + [`/projects/${projectId}/tasks`, { title: 'Same title' }], + ['/events', { title: 'Same event', start: '2026-09-12T10:00:00Z', end: '2026-09-12T11:00:00Z' }], + ] as const) { + const key = randomUUID(); + const first = await create(route, body, key); + assert.equal(first.status, 201, JSON.stringify(first.body)); + const replies = await Promise.all(Array.from({ length: 8 }, () => create(route, body, key))); + for (const reply of replies) { assert.equal(reply.status, 201); assert.equal(reply.body.id, first.body.id); } + if ('content' in body) { + const delivered = await db.select().from(notifications).where(and( + eq(notifications.org_id, orgId), eq(notifications.user_id, sameOrgUser), + sql`${notifications.metadata}->>'message_id' = ${first.body.id}`, + )); + assert.equal(delivered.length, 1, 'explicit replay must not duplicate recipient notifications'); + const projectionJobs = await db.select().from(jobQueue).where(and( + eq(jobQueue.name, 'notification-attention-sync'), + sql`${jobQueue.data}->>'orgId' = ${orgId}`, + sql`${jobQueue.data}->'notificationIds' @> ${JSON.stringify([delivered[0]!.id])}::jsonb`, + )); + assert.equal(projectionJobs.length, 1, 'explicit replay must not enqueue another projection'); + } + const distinct = await create(route, body, randomUUID()); + assert.equal(distinct.status, 201); assert.notEqual(distinct.body.id, first.body.id); + const conflict = await create(route, { ...body, ...('content' in body ? { content: 'Changed' } : { title: 'Changed' }) }, key); + assert.equal(conflict.status, 409); assert.equal(conflict.body.code, 'IDEMPOTENCY_CONFLICT'); + assert.equal((await create(route, body, 'bad key')).status, 400); + } +}); + +test('keyless creates retain legacy distinct-create behavior', async () => { + for (const [route, body] of [ + [`/messages/${spaceId}`, { content: 'Unkeyed message' }], + [`/projects/${projectId}/tasks`, { title: 'Unkeyed task' }], + ['/events', { title: 'Unkeyed event', start: '2026-09-13T10:00:00Z', end: '2026-09-13T11:00:00Z' }], + ] as const) { + const first = await create(route, body); + const second = await create(route, body); + assert.equal(first.status, 201, JSON.stringify(first.body)); + assert.equal(second.status, 201, JSON.stringify(second.body)); + assert.notEqual(second.body.id, first.body.id); + } +}); + +test('the same key belongs to the current actor within an organization', async () => { + const key = randomUUID(); + const body = { content: 'Actor-scoped replay' }; + const owner = await create(`/messages/${spaceId}`, body, key); + const member = await create(`/messages/${spaceId}`, body, key, false, true); + assert.equal(owner.status, 201, JSON.stringify(owner.body)); + assert.equal(member.status, 201, JSON.stringify(member.body)); + assert.notEqual(member.body.id, owner.body.id); + assert.equal(member.body.user_id, sameOrgUser); +}); + +test('task aliases replay one task and one activity, with current tenant authorization', async () => { + const key = randomUUID(), body = { title: 'Alias replay' }; + const first = await create(`/projects/${projectId}/tasks`, body, key); + for (const [route, data] of [[`/tasks/project/${projectId}`, body], ['/tasks', { ...body, project_id: projectId }]] as const) { + const replay = await create(route, data, key); + assert.equal(replay.status, 201, JSON.stringify(replay.body)); assert.equal(replay.body.id, first.body.id); + } + assert.equal((await db.select().from(taskActivity).where(eq(taskActivity.task_id, first.body.id))).length, 1); + assert.equal((await create(`/projects/${projectId}/tasks`, body, key, true)).status, 404); + await db.update(tasks).set({ is_deleted: true }).where(eq(tasks.id, first.body.id)); + assert.equal((await create(`/projects/${projectId}/tasks`, body, key)).status, 404); +}); + +test('attachment claim is atomic with replay identity; revocation and deleted messages do not replay', async () => { + const fileId = randomUUID(); + await db.insert(files).values({ id: fileId, org_id: orgId, uploaded_by: userId, filename: 'replay.txt', mime_type: 'text/plain', size_bytes: 1, storage_key: `replay-${fileId}` }); + const key = randomUUID(), body = { content: 'Attachment', file_ids: [fileId] }; + const first = await create(`/messages/${spaceId}`, body, key); + const replay = await create(`/messages/${spaceId}`, body, key); + assert.equal(first.status, 201); assert.equal(replay.body.id, first.body.id); assert.equal(replay.body.files.length, 1); + assert.equal((await db.select().from(messageAttachments).where(eq(messageAttachments.file_id, fileId))).length, 1); + assert.equal((await create(`/messages/${spaceId}`, body, key, true)).status, 403); + await db.delete(spaceMembers).where(and(eq(spaceMembers.space_id, spaceId), eq(spaceMembers.user_id, userId))); + assert.equal((await create(`/messages/${spaceId}`, body, key)).status, 403); + await db.insert(spaceMembers).values({ space_id: spaceId, user_id: userId }); + await db.update(messages).set({ is_deleted: true }).where(eq(messages.id, first.body.id)); + assert.equal((await create(`/messages/${spaceId}`, body, key)).status, 404); + const failedKey = randomUUID(); + const failed = await create(`/messages/${spaceId}`, body, failedKey); + assert.equal(failed.status, 404); + assert.equal(failed.body.code, 'ATTACHMENT_NOT_FOUND'); + // A rolled-back attachment claim must not reserve the request identity. + assert.equal((await create(`/messages/${spaceId}`, { content: 'Corrected after validation' }, failedKey)).status, 201); +}); + +test('event identity is scoped to owner and survives deletion as a tombstone', async () => { + const key = randomUUID(), body = { title: 'Private', start: '2026-09-12T10:00:00Z', end: '2026-09-12T11:00:00Z' }; + const first = await create('/events', body, key); + const other = await create('/events', body, key, true); + assert.equal(other.status, 201); assert.notEqual(other.body.id, first.body.id); assert.equal(other.body.org_id, otherOrg); + await db.delete(events).where(eq(events.id, first.body.id)); + assert.equal((await create('/events', body, key)).status, 404); +}); + +after(async () => { + const ids = [orgId, otherOrg]; + await db.delete(jobQueue).where(sql`${jobQueue.data}->>'orgId' IN (${orgId}, ${otherOrg})`); + await db.delete(notifications).where(inArray(notifications.user_id, [userId, sameOrgUser, otherUser])); + await db.delete(messageAttachments).where(inArray(messageAttachments.org_id, ids)); + await db.delete(files).where(inArray(files.org_id, ids)); + await db.delete(messages).where(inArray(messages.org_id, ids)); + await db.delete(taskActivity).where(inArray(taskActivity.org_id, ids)); + await db.delete(tasks).where(inArray(tasks.org_id, ids)); + await db.delete(events).where(inArray(events.org_id, ids)); + await db.delete(projects).where(inArray(projects.org_id, ids)); + await db.delete(spaceMembers).where(eq(spaceMembers.space_id, spaceId)); + await db.delete(spaces).where(eq(spaces.id, spaceId)); + await db.delete(nativeCreateRequests).where(inArray(nativeCreateRequests.org_id, ids)); + await db.delete(orgMembers).where(inArray(orgMembers.org_id, ids)); + await db.delete(users).where(inArray(users.id, [userId, sameOrgUser, otherUser])); + await db.delete(orgs).where(inArray(orgs.id, ids)); + await closeDb(); +}); diff --git a/apps/api/test/oauth-mcp.test.ts b/apps/api/test/oauth-mcp.test.ts index aba4e036..79df880c 100644 --- a/apps/api/test/oauth-mcp.test.ts +++ b/apps/api/test/oauth-mcp.test.ts @@ -469,6 +469,26 @@ test('OAuth metadata and dynamic client registration describe the remote MCP con const scopeLessClient = (await scopeLessClientRes.json()) as Record; assert.ok(String(scopeLessClient.client_id).startsWith('deft_dcr_')); assert.equal(Object.hasOwn(scopeLessClient, 'scope'), false); + + const extensionClientRes = await jsonPost('/oauth/register', { + client_name: `OAuth MCP Test Extension ${TEST_ID}`, + redirect_uris: ['http://localhost:3999/callback'], + software_id: 'standard-client-extension', + software_version: '1.2.3', + contacts: ['operator@example.test'], + }); + assert.equal(extensionClientRes.status, 201); + const extensionClient = await extensionClientRes.json() as { client_id: string }; + const storedMetadata = await withClient(async (client) => { + const result = await client.query<{ metadata: Record }>( + 'SELECT metadata FROM oauth_clients WHERE client_id = $1', + [extensionClient.client_id], + ); + return result.rows[0]!.metadata; + }); + assert.equal(Object.hasOwn(storedMetadata, 'software_id'), false); + assert.equal(Object.hasOwn(storedMetadata, 'contacts'), false); + assert.deepEqual(storedMetadata.redirect_uris, ['http://localhost:3999/callback']); }); test('OAuth PKCE token exchange resolves to a scoped human MCP principal', async () => { diff --git a/apps/api/test/oauth-public-hardening.test.ts b/apps/api/test/oauth-public-hardening.test.ts new file mode 100644 index 00000000..e0b571a7 --- /dev/null +++ b/apps/api/test/oauth-public-hardening.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Hono } from 'hono'; + +process.env.DEFT_OAUTH_PUBLIC_RATE_LIMIT_PER_MINUTE = '5'; + +const { oauthPublicRoutes } = await import('../src/routes/oauth-mcp.js'); +const { connectionRoutes } = await import('../src/routes/connections.js'); + +function appFor(routes: Hono, prefix: string) { + const app = new Hono(); + app.route(prefix, routes); + return app; +} + +const oauth = appFor(oauthPublicRoutes, '/oauth'); + +async function json(path: string, body: unknown, forwardedFor = '198.51.100.1') { + return oauth.request(path, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-forwarded-for': forwardedFor }, + body: JSON.stringify(body), + }); +} + +test('public OAuth rejects oversized and non-scalar input with structured errors', async () => { + const oversized = await oauth.request('/oauth/register', { + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': '70000' }, + body: JSON.stringify({ client_name: 'x'.repeat(70_000), redirect_uris: ['http://localhost/callback'] }), + }); + assert.equal(oversized.status, 413); + assert.equal((await oversized.json() as any).error, 'invalid_request'); + + const cases: Array<[string, unknown]> = [ + ['/oauth/register', null], + ['/oauth/register', { redirect_uris: { callback: 'http://localhost/callback' }, software_id: 'extension-is-ignored' }], + ['/oauth/token', { grant_type: ['authorization_code'], client_id: 'client' }], + ['/oauth/token', { grant_type: 'refresh_token', client_id: {}, refresh_token: ['token'] }], + ['/oauth/revoke', { token: { nested: 'token' } }], + ]; + for (const [path, body] of cases) { + const response = await json(path, body); + assert.equal(response.status, 400, `${path} accepted ${JSON.stringify(body)}`); + const payload = await response.json() as { error?: string; error_description?: string }; + assert.match(payload.error ?? '', /invalid_(request|client_metadata)/); + assert.equal(typeof payload.error_description, 'string'); + } +}); + +test('public OAuth global budget cannot be bypassed with forged forwarded IPs', async () => { + const response = await json('/oauth/revoke', { client_id: 'legitimate-public-client', extension: true }, '203.0.113.250'); + assert.equal(response.status, 429); + assert.equal((await response.json() as any).error, 'temporarily_unavailable'); +}); + +test('native provider connect and callback are unavailable before any exchange', async () => { + const connections = appFor(connectionRoutes, '/api/connections'); + const originalFetch = globalThis.fetch; + let exchanges = 0; + globalThis.fetch = (async () => { + exchanges += 1; + throw new Error('provider exchange must not run'); + }) as typeof fetch; + try { + for (const [path, method] of [ + ['/api/connections/github/connect', 'POST'], + ['/api/connections/github/callback?code=attacker&state=unsigned', 'GET'], + ] as const) { + const response = await connections.request(path, { method }); + assert.equal(response.status, 404); + assert.equal((await response.json() as any).code, 'NOT_FOUND'); + } + assert.equal(exchanges, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/apps/api/test/profile-settings.test.ts b/apps/api/test/profile-settings.test.ts index cb42e78a..41d80441 100644 --- a/apps/api/test/profile-settings.test.ts +++ b/apps/api/test/profile-settings.test.ts @@ -2,13 +2,12 @@ import { after, before, describe, test } from 'node:test'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import pg from 'pg'; -import jwt from 'jsonwebtoken'; import bcrypt from 'bcryptjs'; import { Hono } from 'hono'; import { authRoutes } from '../src/routes/auth.js'; import { messageRoutes } from '../src/routes/messages.js'; import { authMiddleware } from '../src/middleware/auth.js'; -import { env } from '../src/lib/env.js'; +import { createWebSession } from '../src/lib/web-sessions.js'; const DATABASE_URL = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/deft'; @@ -33,15 +32,13 @@ const USER_ID = crypto.randomUUID(); const SPACE_ID = crypto.randomUUID(); const EMAIL = `profile-${RUN_ID}@test.local`; -function accessToken() { - return jwt.sign({ id: USER_ID, email: EMAIL, org_id: ORG_ID }, env.JWT_SECRET, { expiresIn: '15m' }); -} +let accessToken = ''; async function authed(path: string, init: RequestInit = {}) { return app.fetch(new Request(`http://localhost${path}`, { ...init, headers: { - Authorization: `Bearer ${accessToken()}`, + Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', ...(init.headers ?? {}), }, @@ -77,10 +74,12 @@ before(async () => { [crypto.randomUUID(), SPACE_ID, USER_ID], ); }); + accessToken = (await createWebSession({ id: USER_ID, email: EMAIL, org_id: ORG_ID })).accessToken; }); after(async () => { await withClient(async (c) => { + await c.query(`DELETE FROM web_sessions WHERE user_id = $1`, [USER_ID]); await c.query(`DELETE FROM messages WHERE space_id = $1`, [SPACE_ID]); await c.query(`DELETE FROM space_members WHERE space_id = $1`, [SPACE_ID]); await c.query(`DELETE FROM spaces WHERE id = $1`, [SPACE_ID]); diff --git a/apps/api/test/socket-readiness.test.ts b/apps/api/test/socket-readiness.test.ts new file mode 100644 index 00000000..cc23efda --- /dev/null +++ b/apps/api/test/socket-readiness.test.ts @@ -0,0 +1,144 @@ +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { setupSocket, type SocketUser } from '../src/socket.js'; + +const { io: connect } = createRequire(new URL('../../web/package.json', import.meta.url))('socket.io-client'); +const user: SocketUser = { + id: 'socket-ready-user', + email: 'socket-ready@deft.invalid', + org_id: 'socket-ready-org', + sid: 'socket-ready-session', + exp: Math.floor(Date.now() / 1000) + 300, +}; + +function deferred() { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +function once(socket: any, event: string, timeoutMs = 5_000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Socket ${event} timed out`)), timeoutMs); + socket.once(event, (value: unknown) => { clearTimeout(timer); resolve(value); }); + }); +} + +async function fixture(secondVerification: Promise, thirdVerification: Promise = Promise.resolve()) { + let verificationCount = 0; + let spaceAccessChecks = 0; + const ingress = deferred(); + const server = createServer(); + const sockets = setupSocket(server, { + verifyAccess: async () => { + verificationCount += 1; + if (verificationCount === 2) await secondVerification; + if (verificationCount === 3) await thirdVerification; + return user as any; + }, + requireMembership: async () => ({ role: 'member' }) as any, + getSpaceAccess: async (spaceId) => { + spaceAccessChecks += 1; + return { space_id: spaceId, space_name: 'Ready room', user_name: 'Ready user' }; + }, + recordLastSeen: () => {}, + onPacketIngress: () => ingress.resolve(), + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as { port: number }).port; + const client = connect(`http://127.0.0.1:${port}`, { + auth: { token: 'test-token' }, transports: ['websocket'], reconnection: false, + }); + return { + client, sockets, + spaceAccessChecks: () => spaceAccessChecks, + ingress: ingress.promise, + close: async () => { + client.close(); + await new Promise((resolve) => sockets.close(() => resolve())); + }, + }; +} + +test('space join emitted on connect waits for initialization and receives room traffic', async () => { + const verification = deferred(); + const run = await fixture(verification.promise); + try { + const connected = once(run.client, 'connect'); + run.client.on('connect', () => run.client.emit('space:join', 'early-room')); + await connected; + await run.ingress; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(run.spaceAccessChecks(), 0, 'early packet must wait for the second access verification'); + const serverSocket = [...run.sockets.sockets.sockets.values()][0]; + assert.deepEqual( + [...serverSocket.rooms].filter((room) => room !== serverSocket.id), + ['web-session:socket-ready-session'], + 'sensitive rooms must remain unavailable before the second verification', + ); + + verification.resolve(); + const received = once(run.client, 'readiness:probe'); + const deadline = Date.now() + 5_000; + while ((run.sockets.sockets.adapter.rooms.get('space:early-room')?.size ?? 0) === 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(run.spaceAccessChecks(), 1, 'queued join must dispatch after handler registration'); + assert.equal(run.sockets.sockets.adapter.rooms.get('space:early-room')?.size, 1); + run.sockets.to('space:early-room').emit('readiness:probe', { ok: true }); + assert.deepEqual(await received, { ok: true }); + } finally { + verification.resolve(); + await run.close(); + } +}); + +test('failed second verification never dispatches an early space join', async () => { + const verification = deferred(); + const run = await fixture(verification.promise); + try { + const connected = once(run.client, 'connect'); + run.client.on('connect', () => run.client.emit('space:join', 'forbidden-room')); + await connected; + await run.ingress; + await new Promise((resolve) => setImmediate(resolve)); + const serverSocket = [...run.sockets.sockets.sockets.values()][0]; + assert.equal(serverSocket.rooms.has('org:socket-ready-org'), false); + assert.equal(serverSocket.rooms.has('user:socket-ready-user'), false); + assert.equal(serverSocket.rooms.has('org-user:socket-ready-org:socket-ready-user'), false); + const disconnected = once(run.client, 'disconnect'); + verification.reject(new Error('revoked during initialization')); + assert.equal(await disconnected, 'io server disconnect'); + assert.equal(run.spaceAccessChecks(), 0); + assert.equal(run.sockets.sockets.adapter.rooms.get('space:forbidden-room')?.size ?? 0, 0); + } finally { + verification.resolve(); + await run.close(); + } +}); + +test('disconnect during per-packet verification prevents space join dispatch', async () => { + const packetVerification = deferred(); + const run = await fixture(Promise.resolve(), packetVerification.promise); + try { + await once(run.client, 'connect'); + run.client.emit('space:join', 'disconnected-room'); + await run.ingress; + run.client.close(); + const deadline = Date.now() + 5_000; + while (run.sockets.sockets.sockets.size > 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(run.sockets.sockets.sockets.size, 0, 'server must observe disconnect before verification resumes'); + packetVerification.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(run.spaceAccessChecks(), 0); + assert.equal(run.sockets.sockets.adapter.rooms.get('space:disconnected-room')?.size ?? 0, 0); + } finally { + packetVerification.resolve(); + await run.close(); + } +}); diff --git a/apps/api/test/teams.test.ts b/apps/api/test/teams.test.ts index 7a5c03e8..d51c105e 100644 --- a/apps/api/test/teams.test.ts +++ b/apps/api/test/teams.test.ts @@ -2,11 +2,10 @@ import { after, before, describe, test } from 'node:test'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import pg from 'pg'; -import jwt from 'jsonwebtoken'; import { Hono } from 'hono'; import { authMiddleware } from '../src/middleware/auth.js'; import { teamRoutes } from '../src/routes/teams.js'; -import { env } from '../src/lib/env.js'; +import { createWebSession } from '../src/lib/web-sessions.js'; const DATABASE_URL = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/deft'; @@ -45,8 +44,10 @@ const OTHER_PROJECT_ID = crypto.randomUUID(); const OVERDUE_TASK_ID = crypto.randomUUID(); const REVIEW_TASK_ID = crypto.randomUUID(); -function token(userId: string, orgId = ORG_ID, email = `${userId}@test.local`) { - return jwt.sign({ id: userId, email, org_id: orgId }, env.JWT_SECRET, { expiresIn: '15m' }); +const sessions = new Map>>(); +const sessionKey = (userId: string, orgId: string) => `${orgId}:${userId}`; +function token(userId: string, orgId = ORG_ID) { + return sessions.get(sessionKey(userId, orgId))!.accessToken; } async function authed(path: string, userId: string, init: RequestInit = {}, orgId = ORG_ID) { @@ -171,10 +172,25 @@ before(async () => { [crypto.randomUUID(), ORG_ID, TEAM_ID, LEAD_ID, crypto.randomUUID(), MEMBER_ID, crypto.randomUUID(), PRIVATE_TEAM_ID], ); }); + const identities = [ + [ADMIN_ID, ORG_ID, `teams-admin-${RUN_ID}@test.local`], + [LEAD_ID, ORG_ID, `teams-lead-${RUN_ID}@test.local`], + [MEMBER_ID, ORG_ID, `teams-member-${RUN_ID}@test.local`], + [TARGET_ID, ORG_ID, `teams-target-${RUN_ID}@test.local`], + [AGENT_USER_ID, ORG_ID, `teams-agent-${RUN_ID}@test.local`], + [OTHER_USER_ID, OTHER_ORG_ID, `teams-other-user-${RUN_ID}@test.local`], + ] as const; + for (const [id, orgId, email] of identities) { + sessions.set(sessionKey(id, orgId), await createWebSession({ id, org_id: orgId, email })); + } + await withClient(c => c.query(`UPDATE org_members SET is_active = true WHERE org_id = $1 AND user_id = $2`, [ORG_ID, INACTIVE_ID]).then(() => undefined)); + sessions.set(sessionKey(INACTIVE_ID, ORG_ID), await createWebSession({ id: INACTIVE_ID, org_id: ORG_ID, email: `teams-inactive-${RUN_ID}@test.local` })); + await withClient(c => c.query(`UPDATE org_members SET is_active = false WHERE org_id = $1 AND user_id = $2`, [ORG_ID, INACTIVE_ID]).then(() => undefined)); }); after(async () => { await withClient(async (c) => { + await c.query(`DELETE FROM web_sessions WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); await c.query(`DELETE FROM team_dashboard_snapshots WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); await c.query(`DELETE FROM team_resources WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); await c.query(`DELETE FROM team_members WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); diff --git a/apps/api/test/web-session-family.test.ts b/apps/api/test/web-session-family.test.ts new file mode 100644 index 00000000..41cc0994 --- /dev/null +++ b/apps/api/test/web-session-family.test.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { randomUUID } from 'node:crypto'; +import jwt from 'jsonwebtoken'; +import { Hono } from 'hono'; +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { eq } from 'drizzle-orm'; + +const databaseUrl = process.env.DEFT_TEST_DATABASE_URL; +test('web session HTTP and database rotation/revocation boundaries', { skip: !databaseUrl }, async (t) => { + const target = new URL(databaseUrl!); + const disposableName = target.pathname.startsWith('/preview_') || (process.env.CI === 'true' && target.pathname === '/deft_test'); + assert.ok(['127.0.0.1', 'localhost'].includes(target.hostname) && disposableName, 'Only disposable local preview or declared CI databases'); + process.env.DATABASE_URL = databaseUrl; + process.env.JWT_SECRET = 'preview-session-tests-access-only'; + process.env.JWT_REFRESH_SECRET = 'preview-session-tests-refresh-only'; + const { db, closeDb } = await import('../src/lib/db.js'); + const { users, orgs, orgMembers, webSessions } = await import('@deft/db/schema'); + const { createWebSession, rotateWebSession, verifyWebAccess, revokeWebSession, changeWebPassword, revokeMemberWebSessions } = await import('../src/lib/web-sessions.js'); + const { authRoutes } = await import('../src/routes/auth.js'); + const { authMiddleware } = await import('../src/middleware/auth.js'); + const userId = randomUUID(); + const orgId = randomUUID(); + const identity = { id: userId, org_id: orgId, email: `${userId}@preview-session.local` }; + await db.insert(users).values({ id: userId, name: 'Session regression', email: identity.email, email_verified: true }); + await db.insert(orgs).values({ id: orgId, name: 'Session regression', slug: `session-${orgId}` }); + await db.insert(orgMembers).values({ org_id: orgId, user_id: userId, role: 'owner' }); + t.after(closeDb); + const app = new Hono(); + app.route('/api/auth', authRoutes); + app.use('/api/*', authMiddleware); + app.get('/api/probe', (c) => c.json({ ok: true })); + const post = (route: string, body: unknown) => app.request(`/api/auth/${route}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const probe = (token: string) => app.request('/api/probe', { headers: { Authorization: `Bearer ${token}` } }); + + await t.test('one-use rotation revokes descendants and access on replay', async () => { + const initial = await createWebSession(identity); + const rotated = await rotateWebSession(initial.refreshToken); + assert.notEqual(rotated.refreshToken, initial.refreshToken); + assert.equal((await probe(rotated.accessToken)).status, 200); + assert.equal((await post('refresh', { refreshToken: initial.refreshToken })).status, 401); + await assert.rejects(() => rotateWebSession(rotated.refreshToken)); + assert.equal((await probe(initial.accessToken)).status, 401); + assert.equal((await probe(rotated.accessToken)).status, 401); + }); + await t.test('concurrent use rotates at most once then fails closed for the family', async () => { + const initial = await createWebSession(identity); + const results = await Promise.allSettled([rotateWebSession(initial.refreshToken), rotateWebSession(initial.refreshToken)]); + assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1); + for (const result of results) if (result.status === 'fulfilled') await assert.rejects(() => verifyWebAccess(result.value.accessToken)); + }); + await t.test('logout revokes access and refresh; repeated logout is idempotent', async () => { + const pair = await createWebSession(identity); + assert.equal((await post('logout', { refreshToken: pair.refreshToken })).status, 200); + assert.equal((await post('logout', { refreshToken: pair.refreshToken })).status, 200); + assert.equal((await probe(pair.accessToken)).status, 401); + assert.equal((await app.request('/api/auth/me', { headers: { Authorization: `Bearer ${pair.accessToken}` } })).status, 401); + await assert.rejects(() => rotateWebSession(pair.refreshToken)); + }); + await t.test('malformed bodies are bounded structured 400 responses', async () => { + for (const route of ['refresh', 'logout', 'login', 'signup']) { + for (const body of [null, [], { refreshToken: {} }]) assert.equal((await post(route, body)).status, 400); + assert.equal((await app.request(`/api/auth/${route}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{' })).status, 400); + } + }); + await t.test('legacy, wrong purpose, expired and inactive-member credentials are denied', async () => { + const pair = await createWebSession(identity); + const legacy = jwt.sign(identity, process.env.JWT_SECRET!, { expiresIn: '15m' }); + assert.equal((await probe(legacy)).status, 401); + assert.equal((await probe(pair.refreshToken)).status, 401); + const sid = (jwt.decode(pair.accessToken) as { sid: string }).sid; + await db.update(webSessions).set({ expires_at: new Date(0) }).where(eq(webSessions.id, sid)); + await assert.rejects(() => verifyWebAccess(pair.accessToken)); + const active = await createWebSession(identity); + await db.update(orgMembers).set({ is_active: false }).where(eq(orgMembers.user_id, userId)); + assert.equal((await probe(active.accessToken)).status, 403); + await assert.rejects(() => rotateWebSession(active.refreshToken)); + await revokeMemberWebSessions(orgId, userId); + await db.update(orgMembers).set({ is_active: true }).where(eq(orgMembers.user_id, userId)); + await assert.rejects(() => verifyWebAccess(active.accessToken), 'reactivating membership must not revive revoked sessions'); + await revokeWebSession(active.refreshToken); + }); + await t.test('password change revokes all user families and reset token is one-use', async () => { + const first = await createWebSession(identity); + const second = await createWebSession(identity); + const resetToken = `disposable-one-use-reset-${userId}`; + await changeWebPassword(userId, 'test-hash-not-for-login', { resetToken, passwordVersion: 0, orgId }); + await assert.rejects(() => verifyWebAccess(first.accessToken)); + await assert.rejects(() => rotateWebSession(second.refreshToken)); + await assert.rejects(() => changeWebPassword(userId, 'replacement', { resetToken, passwordVersion: 0, orgId })); + await assert.rejects(() => changeWebPassword(userId, 'replacement', { resetToken: `other-${resetToken}`, passwordVersion: 0, orgId }), 'all older reset links are invalid after any password change'); + await assert.rejects(() => changeWebPassword(userId, 'replacement', { expectedPasswordHash: 'old-password-hash' }), 'a stale password comparison cannot overwrite a concurrent reset'); + const [user] = await db.select().from(users).where(eq(users.id, userId)); + assert.equal(user.password_hash, 'test-hash-not-for-login'); + await assert.rejects(() => createWebSession(identity, 'old-password-hash')); + }); + await t.test('HTTP reset links bind the current password generation and active organization', async () => { + const [before] = await db.select().from(users).where(eq(users.id, userId)); + const reset = (org_id: string, generation = before.password_version) => jwt.sign({ + id: userId, org_id, purpose: 'password-reset', password_version: generation, jti: randomUUID(), + }, process.env.JWT_SECRET!, { expiresIn: '15m' }); + const pair = await createWebSession(identity); + const token = reset(orgId); + const sibling = reset(orgId); + const password = 'Reset-regression-only-2026!'; + assert.equal((await post('reset-password', { token: reset(randomUUID()), password })).status, 400); + const results = await Promise.all([ + post('reset-password', { token, password }), + post('reset-password', { token: sibling, password: `${password}other` }), + ]); + assert.deepEqual(results.map((r) => r.status).sort(), [200, 400]); + assert.equal((await post('reset-password', { token, password })).status, 400); + assert.equal((await post('reset-password', { token: sibling, password })).status, 400); + const [after] = await db.select().from(users).where(eq(users.id, userId)); + assert.equal(after.password_version, before.password_version + 1); + await assert.rejects(() => verifyWebAccess(pair.accessToken)); + const legacy = jwt.sign({ id: userId, org_id: orgId, purpose: 'password-reset' }, process.env.JWT_SECRET!, { expiresIn: '15m' }); + assert.equal((await post('reset-password', { token: legacy, password })).status, 400); + }); + await t.test('a real connected socket is evicted on logout and rejects revoked reconnect', async () => { + const { setupSocket } = await import('../src/socket.js'); + const { io: connect } = createRequire(new URL('../../web/package.json', import.meta.url))('socket.io-client'); + const server = createServer(); + const sockets = setupSocket(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as { port: number }).port; + const pair = await createWebSession(identity); + const client = connect(`http://127.0.0.1:${port}`, { auth: { token: pair.accessToken }, transports: ['websocket'], reconnection: false }); + const event = (name: string) => new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Socket ${name} timed out`)), 8000); + client.once(name, (value: unknown) => { clearTimeout(timer); resolve(value); }); + }); + try { + await event('connect'); + const disconnected = event('disconnect'); + await revokeWebSession(pair.refreshToken); + assert.equal(await disconnected, 'io server disconnect'); + const denied = event('connect_error'); + client.connect(); + assert.match(String(await denied), /Invalid token/); + } finally { + client.close(); + await new Promise((resolve) => sockets.close(() => resolve())); + } + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 23368004..bfe00e20 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,27 +17,27 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@tailwindcss/postcss": "^4.3.3", - "@tiptap/core": "^3.30.1", - "@tiptap/extension-code-block-lowlight": "^3.30.1", - "@tiptap/extension-details": "^3.30.1", - "@tiptap/extension-document": "^3.30.1", - "@tiptap/extension-highlight": "^3.30.1", - "@tiptap/extension-image": "^3.30.1", - "@tiptap/extension-link": "^3.30.1", - "@tiptap/extension-paragraph": "^3.30.1", - "@tiptap/extension-placeholder": "^3.30.1", - "@tiptap/extension-table": "^3.30.1", - "@tiptap/extension-table-cell": "^3.30.1", - "@tiptap/extension-table-header": "^3.30.1", - "@tiptap/extension-table-row": "^3.30.1", - "@tiptap/extension-task-item": "^3.30.1", - "@tiptap/extension-task-list": "^3.30.1", - "@tiptap/extension-text": "^3.30.1", - "@tiptap/extension-underline": "^3.30.1", - "@tiptap/pm": "^3.30.1", - "@tiptap/react": "^3.30.1", - "@tiptap/starter-kit": "^3.30.1", - "@tiptap/suggestion": "^3.30.1", + "@tiptap/core": "^3.30.4", + "@tiptap/extension-code-block-lowlight": "^3.30.4", + "@tiptap/extension-details": "^3.30.4", + "@tiptap/extension-document": "^3.30.4", + "@tiptap/extension-highlight": "^3.30.4", + "@tiptap/extension-image": "^3.30.4", + "@tiptap/extension-link": "^3.30.4", + "@tiptap/extension-paragraph": "^3.30.4", + "@tiptap/extension-placeholder": "^3.30.4", + "@tiptap/extension-table": "^3.30.4", + "@tiptap/extension-table-cell": "^3.30.4", + "@tiptap/extension-table-header": "^3.30.4", + "@tiptap/extension-table-row": "^3.30.4", + "@tiptap/extension-task-item": "^3.30.4", + "@tiptap/extension-task-list": "^3.30.4", + "@tiptap/extension-text": "^3.30.4", + "@tiptap/extension-underline": "^3.30.4", + "@tiptap/pm": "^3.30.4", + "@tiptap/react": "^3.30.4", + "@tiptap/starter-kit": "^3.30.4", + "@tiptap/suggestion": "^3.30.4", "@types/react-resizable": "^4.0.0", "@types/turndown": "^5.0.6", "d3-drag": "^3.0.0", diff --git a/apps/web/src/app/(app)/notes/note-load-state.test.ts b/apps/web/src/app/(app)/notes/note-load-state.test.ts new file mode 100644 index 00000000..9e6fc9c5 --- /dev/null +++ b/apps/web/src/app/(app)/notes/note-load-state.test.ts @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { noteLoadFailureState } from './note-load-state'; + +test('denied and missing note links use the same unavailable state', () => { + assert.equal(noteLoadFailureState(403), 'unavailable'); + assert.equal(noteLoadFailureState(404), 'unavailable'); +}); + +test('transport and server failures remain retryable errors', () => { + assert.equal(noteLoadFailureState(401), 'error'); + assert.equal(noteLoadFailureState(500), 'error'); +}); diff --git a/apps/web/src/app/(app)/notes/note-load-state.ts b/apps/web/src/app/(app)/notes/note-load-state.ts new file mode 100644 index 00000000..02cb4403 --- /dev/null +++ b/apps/web/src/app/(app)/notes/note-load-state.ts @@ -0,0 +1,6 @@ +export type NoteLoadState = 'unavailable' | 'error'; + +/** Map note fetch failures to the least revealing user-facing state. */ +export function noteLoadFailureState(status: number): NoteLoadState { + return status === 403 || status === 404 ? 'unavailable' : 'error'; +} diff --git a/apps/web/src/app/(app)/notes/note-save-coordinator.test.ts b/apps/web/src/app/(app)/notes/note-save-coordinator.test.ts new file mode 100644 index 00000000..8afed402 --- /dev/null +++ b/apps/web/src/app/(app)/notes/note-save-coordinator.test.ts @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { NoteSaveCoordinator } from './note-save-coordinator'; + +test('does not report saved while a body edit is still pending behind a title save', async () => { + const requests: Array> = []; + const statuses: string[] = []; + const coordinator = new NoteSaveCoordinator(async (payload) => { + requests.push(payload); + return true; + }, status => statuses.push(status)); + + const bodyRevision = coordinator.markDirty('content'); + const titleRevision = coordinator.markDirty('title'); + await coordinator.save('title', titleRevision, { title: 'Changed title' }); + + assert.equal(coordinator.status, 'saving'); + await coordinator.save('content', bodyRevision, { content: '

Changed body

' }); + assert.equal(coordinator.status, 'saved'); + assert.deepEqual(requests, [ + { title: 'Changed title' }, + { content: '

Changed body

' }, + ]); + assert.equal(statuses.includes('saved'), true); +}); + +test('keeps a failed field dirty and never reports saved after an HTTP failure', async () => { + const coordinator = new NoteSaveCoordinator(async () => false); + const revision = coordinator.markDirty('content'); + + await coordinator.save('content', revision, { content: '

Not persisted

' }); + + assert.equal(coordinator.status, 'error'); + assert.equal(coordinator.isDirty('content'), true); +}); + +test('serializes note writes so delayed responses cannot overwrite newer fields out of order', async () => { + const releases: Array<() => void> = []; + const started: string[] = []; + const coordinator = new NoteSaveCoordinator(async (payload) => { + started.push(Object.keys(payload)[0]); + await new Promise(resolve => releases.push(resolve)); + return true; + }); + const bodyRevision = coordinator.markDirty('content'); + const titleRevision = coordinator.markDirty('title'); + + const bodySave = coordinator.save('content', bodyRevision, { content: 'body' }); + const titleSave = coordinator.save('title', titleRevision, { title: 'title' }); + await Promise.resolve(); + assert.deepEqual([...started], ['content']); + releases.shift()?.(); + await bodySave; + await Promise.resolve(); + assert.deepEqual([...started], ['content', 'title']); + releases.shift()?.(); + await Promise.all([bodySave, titleSave]); + assert.equal(coordinator.status, 'saved'); +}); + +test('awaitIdle waits for a debounce-fired in-flight failure before Back decides to leave', async () => { + let release!: (ok: boolean) => void; + const coordinator = new NoteSaveCoordinator(() => new Promise(resolve => { release = resolve; })); + const revision = coordinator.markDirty('content'); + void coordinator.save('content', revision, { content: 'delayed body' }); + await Promise.resolve(); + + let settled = false; + const idle = coordinator.awaitIdle().then(() => { settled = true; }); + await Promise.resolve(); + assert.equal(settled, false); + + release(false); + await idle; + assert.equal(coordinator.status, 'error'); +}); + +test('a new dirty edit made while Back awaits an older save keeps the editor unsettled', async () => { + let release!: () => void; + const coordinator = new NoteSaveCoordinator(async () => { + await new Promise(resolve => { release = resolve; }); + return true; + }); + const firstRevision = coordinator.markDirty('content'); + void coordinator.save('content', firstRevision, { content: 'first edit' }); + await Promise.resolve(); + + const idle = coordinator.awaitIdle(); + coordinator.markDirty('content'); + release(); + await idle; + + assert.equal(coordinator.status, 'saving'); + assert.equal(coordinator.isDirty('content'), true); +}); diff --git a/apps/web/src/app/(app)/notes/note-save-coordinator.ts b/apps/web/src/app/(app)/notes/note-save-coordinator.ts new file mode 100644 index 00000000..711b3741 --- /dev/null +++ b/apps/web/src/app/(app)/notes/note-save-coordinator.ts @@ -0,0 +1,84 @@ +export type NoteSaveStatus = 'idle' | 'saving' | 'saved' | 'error'; + +type SaveRequest = (payload: Record) => Promise; +type StatusListener = (status: NoteSaveStatus) => void; + +/** + * Keeps every note mutation on one ordered lane and tracks dirtiness per field. + * A successful title/icon write therefore cannot acknowledge an unsent body edit. + */ +export class NoteSaveCoordinator { + private revisions = new Map(); + private dirty = new Map(); + private failed = new Map(); + private queue: Promise = Promise.resolve(); + private inFlight = 0; + private currentStatus: NoteSaveStatus = 'idle'; + + constructor( + private readonly request: SaveRequest, + private readonly onStatus: StatusListener = () => {}, + ) {} + + get status(): NoteSaveStatus { + return this.currentStatus; + } + + markDirty(field: string): number { + const revision = (this.revisions.get(field) ?? 0) + 1; + this.revisions.set(field, revision); + this.dirty.set(field, revision); + this.failed.delete(field); + this.setStatus('saving'); + return revision; + } + + isDirty(field: string): boolean { + return this.dirty.has(field); + } + + async awaitIdle(): Promise { + while (true) { + const observedQueue = this.queue; + await observedQueue; + if (this.queue === observedQueue) return; + } + } + + save(field: string, revision: number, payload: Record): Promise { + const operation = async () => { + this.inFlight += 1; + this.updateStatus(); + try { + const ok = await this.request(payload); + if (ok) { + if (this.dirty.get(field) === revision) this.dirty.delete(field); + if (this.failed.get(field) === revision) this.failed.delete(field); + } else if (this.dirty.get(field) === revision) { + this.failed.set(field, revision); + } + } catch { + if (this.dirty.get(field) === revision) this.failed.set(field, revision); + } finally { + this.inFlight -= 1; + this.updateStatus(); + } + }; + + const result = this.queue.then(operation, operation); + this.queue = result.catch(() => {}); + return result; + } + + private updateStatus() { + if (this.failed.size > 0) this.setStatus('error'); + else if (this.dirty.size > 0 || this.inFlight > 0) this.setStatus('saving'); + else this.setStatus('saved'); + } + + private setStatus(status: NoteSaveStatus) { + if (this.currentStatus === status) return; + this.currentStatus = status; + this.onStatus(status); + } +} diff --git a/apps/web/src/app/(app)/notes/page.tsx b/apps/web/src/app/(app)/notes/page.tsx index 0cfab9df..efdfe23f 100644 --- a/apps/web/src/app/(app)/notes/page.tsx +++ b/apps/web/src/app/(app)/notes/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; import { sanitizeHtml } from '@/lib/sanitize'; @@ -18,7 +18,6 @@ import { Table } from '@tiptap/extension-table'; import { TableRow } from '@tiptap/extension-table-row'; import { TableCell } from '@tiptap/extension-table-cell'; import { TableHeader } from '@tiptap/extension-table-header'; -import { Image as TiptapImage } from '@tiptap/extension-image'; import { TaskList } from '@tiptap/extension-task-list'; import { TaskItem } from '@tiptap/extension-task-item'; import { Highlight } from '@tiptap/extension-highlight'; @@ -40,6 +39,9 @@ import { useSetPageContext } from '@/components/app-header-context'; import { OverflowMenu } from '@/components/overflow-menu'; import { AppBottomSheet } from '@/components/overlay-primitives'; import { stripHtml } from '@/lib/strip-html'; +import { NoteSaveCoordinator, type NoteSaveStatus } from './note-save-coordinator'; +import { noteImageAttributes, ProtectedNoteImage } from './protected-note-image'; +import { noteLoadFailureState, type NoteLoadState } from './note-load-state'; // Register built-in slash menu commands (idempotent). registerBuiltInCommands(); @@ -203,7 +205,9 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () const { user } = useAuth(); const [note, setNote] = useState(null); const [loading, setLoading] = useState(true); - const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); + const [loadState, setLoadState] = useState<'loading' | 'loaded' | NoteLoadState>('loading'); + const [loadAttempt, setLoadAttempt] = useState(0); + const [saveStatus, setSaveStatus] = useState('idle'); const [title, setTitle] = useState(''); const [icon, setIcon] = useState(null); const [visibility, setVisibility] = useState<'private' | 'org'>('private'); @@ -235,8 +239,27 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () const initialContentSet = useRef(false); const titleDebounce = useRef>(undefined); const deleteTimerRef = useRef>(undefined); + const pendingContentSave = useRef<{ revision: number; payload: Record } | null>(null); + const pendingTitleSave = useRef<{ revision: number; payload: Record } | null>(null); + const noteLoadRequestRef = useRef(0); + const initialContentTimerRef = useRef | undefined>(undefined); + + const saveCoordinator = useMemo(() => new NoteSaveCoordinator( + async payload => { + const response = await api.patch(`/api/daily-notes/${noteId}`, payload); + return response.ok; + }, + status => { + if (savedTimerRef.current) clearTimeout(savedTimerRef.current); + setSaveStatus(status); + if (status === 'saved') { + savedTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000); + } + }, + ), [noteId]); - const isNoteOwner = !note || note.user_id === user?.id; + // Keep the editor locked until an authorized note payload arrives. + const isNoteOwner = Boolean(note && note.user_id === user?.id); const editor = useEditor({ immediatelyRender: false, @@ -253,7 +276,7 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () TableRow, TableCell, TableHeader, - TiptapImage.configure({ inline: false, allowBase64: false }), + ProtectedNoteImage.configure({ inline: false, allowBase64: false }), TaskList, TaskItem.configure({ nested: true }), Highlight.configure({ multicolor: false }), @@ -277,10 +300,10 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () api.upload('/api/upload', file).then(async (res) => { if (res.ok) { const data = await res.json(); - const imgNode = view.state.schema.nodes.image; + const imgNode = view.state.schema.nodes.protectedNoteImage; if (imgNode) { view.dispatch(view.state.tr.replaceSelectionWith( - imgNode.create({ src: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/api/files/${data.id}` }) + imgNode.create(noteImageAttributes({ id: data.id, name: file.name })) )); } } @@ -293,13 +316,14 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () }, onUpdate: ({ editor: ed }) => { if (!initialContentSet.current) return; - setSaveStatus('saving'); + const revision = saveCoordinator.markDirty('content'); + pendingContentSave.current = { revision, payload: { content: ed.getHTML() } }; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - saveTimerRef.current = setTimeout(async () => { - await api.patch(`/api/daily-notes/${noteId}`, { content: ed.getHTML() }); - setSaveStatus('saved'); - if (savedTimerRef.current) clearTimeout(savedTimerRef.current); - savedTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000); + saveTimerRef.current = setTimeout(() => { + const pending = pendingContentSave.current; + if (!pending || pending.revision !== revision) return; + pendingContentSave.current = null; + void saveCoordinator.save('content', pending.revision, pending.payload); }, 600); }, }); @@ -315,7 +339,10 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () const res = await api.upload('/api/upload', file); if (res.ok) { const data = await res.json(); - editor.chain().focus().setImage({ src: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/api/files/${data.id}` }).run(); + editor.chain().focus().insertContent({ + type: 'protectedNoteImage', + attrs: noteImageAttributes({ id: data.id, name: file.name }), + }).run(); } } catch {} }; @@ -335,27 +362,11 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () URL.revokeObjectURL(url); }, [editor, note, title]); - const clearSavedTimers = useCallback(() => { - if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - if (savedTimerRef.current) clearTimeout(savedTimerRef.current); - }, []); - - const markSaved = useCallback(() => { - clearSavedTimers(); - setSaveStatus('saved'); - savedTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000); - }, [clearSavedTimers]); - const persistNoteUpdate = useCallback(async (payload: Record) => { - setSaveStatus('saving'); - try { - const res = await api.patch(`/api/daily-notes/${noteId}`, payload); - if (res.ok) markSaved(); - else setSaveStatus('idle'); - } catch { - setSaveStatus('idle'); - } - }, [markSaved, noteId]); + const field = Object.keys(payload)[0] || 'metadata'; + const revision = saveCoordinator.markDirty(field); + await saveCoordinator.save(field, revision, payload); + }, [saveCoordinator]); const handlePromoteToWiki = async () => { if (!editor || !note || !title.trim()) return; @@ -423,25 +434,41 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () }; useEffect(() => { + const requestId = ++noteLoadRequestRef.current; setLoading(true); + setLoadState('loading'); + setNote(null); initialContentSet.current = false; + if (initialContentTimerRef.current) clearTimeout(initialContentTimerRef.current); api.get(`/api/daily-notes/${noteId}`).then(async res => { - if (res.ok) { - const data = await res.json(); - setNote(data); - setTitle(data.title); - setIcon(data.icon); - setVisibility(data.visibility === 'org' ? 'org' : 'private'); - if (editor) { - editor.commands.setContent(data.content || ''); - // Set editable based on ownership - editor.setEditable(data.user_id === user?.id); - setTimeout(() => { initialContentSet.current = true; }, 50); - } + if (requestId !== noteLoadRequestRef.current) return; + if (!res.ok) { + // Keep denied and missing direct links indistinguishable to avoid leaking private-note state. + setLoadState(noteLoadFailureState(res.status)); + setLoading(false); + return; + } + const data = await res.json(); + if (requestId !== noteLoadRequestRef.current) return; + setNote(data); + setTitle(data.title); + setIcon(data.icon); + setVisibility(data.visibility === 'org' ? 'org' : 'private'); + if (editor) { + editor.commands.setContent(data.content || ''); + editor.setEditable(data.user_id === user?.id); + initialContentTimerRef.current = setTimeout(() => { + if (requestId === noteLoadRequestRef.current) initialContentSet.current = true; + }, 50); } + setLoadState('loaded'); + setLoading(false); + }).catch(() => { + if (requestId !== noteLoadRequestRef.current) return; + setLoadState('error'); setLoading(false); }); - }, [noteId, editor]); + }, [noteId, editor, user?.id, loadAttempt]); // Task 5.1 — load note -> tasks references sidebar const fetchReferences = useCallback(async () => { @@ -467,19 +494,36 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () const handleTitleChange = (value: string) => { setTitle(value); - setSaveStatus('saving'); + const revision = saveCoordinator.markDirty('title'); + pendingTitleSave.current = { revision, payload: { title: value } }; if (titleDebounce.current) clearTimeout(titleDebounce.current); - titleDebounce.current = setTimeout(async () => { - try { - const res = await api.patch(`/api/daily-notes/${noteId}`, { title: value }); - if (res.ok) markSaved(); - else setSaveStatus('idle'); - } catch { - setSaveStatus('idle'); - } + titleDebounce.current = setTimeout(() => { + const pending = pendingTitleSave.current; + if (!pending || pending.revision !== revision) return; + pendingTitleSave.current = null; + void saveCoordinator.save('title', pending.revision, pending.payload); }, 500); }; + const flushPendingSaves = useCallback(async () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + if (titleDebounce.current) clearTimeout(titleDebounce.current); + const content = pendingContentSave.current; + const pendingTitle = pendingTitleSave.current; + pendingContentSave.current = null; + pendingTitleSave.current = null; + const saves: Promise[] = []; + if (content) saves.push(saveCoordinator.save('content', content.revision, content.payload)); + if (pendingTitle) saves.push(saveCoordinator.save('title', pendingTitle.revision, pendingTitle.payload)); + await Promise.all(saves); + }, [saveCoordinator]); + + const handleEditorBack = useCallback(async () => { + await flushPendingSaves(); + await saveCoordinator.awaitIdle(); + if (saveCoordinator.status === 'saved' || saveCoordinator.status === 'idle') onBack(); + }, [flushPendingSaves, onBack, saveCoordinator]); + const handleIconChange = async (emoji: string) => { setIcon(emoji); setIconPickerOpen(false); @@ -519,12 +563,19 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () setPendingDelete(false); }; - // Clean up delete timer on unmount + // Clean up editor timers on unmount. The explicit Back action awaits the + // coordinator; browser/tab teardown remains best-effort by design. useEffect(() => { - return () => { if (deleteTimerRef.current) clearTimeout(deleteTimerRef.current); }; - }, []); + return () => { + ++noteLoadRequestRef.current; + if (initialContentTimerRef.current) clearTimeout(initialContentTimerRef.current); + if (deleteTimerRef.current) clearTimeout(deleteTimerRef.current); + void flushPendingSaves(); + if (savedTimerRef.current) clearTimeout(savedTimerRef.current); + }; + }, [flushPendingSaves]); - const isOwner = !note || note.user_id === user?.id; + const isOwner = Boolean(note && note.user_id === user?.id); if (loading) { return ( @@ -534,6 +585,23 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () ); } + if (loadState === 'unavailable' || loadState === 'error') { + const unavailable = loadState === 'unavailable'; + return ( +
+
+ {unavailable && } +

{unavailable ? 'Note unavailable' : 'Couldn’t load this note'}

+

{unavailable ? 'You don’t have access to this note, or it no longer exists.' : 'Try again, or return to your notes.'}

+
+ + +
+
+
+ ); + } + return (
@@ -559,7 +627,7 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () > {/* Top bar */}
-