eeed998c9ea7a54794384d7c37c580af21a729ab
405 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
eeed998c9e |
Let users pick their workspace subdomain during sign-up (#21641)
## What & why
During onboarding the workspace subdomain was auto-generated at sign-up
and only editable later in Settings. This adds a subdomain picker to the
workspace-creation flow, with **live availability checking** and
**name-driven auto-fill**.
The subdomain is chosen **on the central sign-up domain, before the
redirect onto the workspace subdomain** — so there's no mid-onboarding
domain switch (which would otherwise force a re-auth, like the Settings
"this logs everyone out" flow). It works uniformly for credentials and
SSO, since workspace creation is a post-auth mutation.
## Flow
Authenticate → **Create a workspace** → new step (workspace name +
address with live availability + auto-fill, seeded from the work email)
→ workspace is created with the chosen subdomain → the single redirect
lands on the final subdomain → onboarding modal (name pre-filled).
## Changes
**twenty-shared**
- `getSubdomainSlugFromDisplayName` — friendly slug from a display name,
built on the existing `transliteration` package (also transliterates
non-Latin names, e.g. 日本語 → `ri-ben-yu`).
**twenty-server**
- `checkWorkspaceSubdomainAvailability(subdomain)` query
(workspace-agnostic, `UserAuthGuard`) → `{ isValid, available,
suggestedSubdomain }`.
- `SubdomainManagerService`: availability + suggestion logic with
friendly numbered suffixes (`acme`, `acme-2`, …) instead of random hex;
`generateSubdomain` reuses it.
- `signUpInNewWorkspace` accepts an optional `{ displayName, subdomain
}` input (validated; falls back to auto-generation when omitted —
backward compatible, so existing callers are unaffected). Concurrent
same-subdomain sign-ups return a clear "already taken" error instead of
a generic DB error.
**twenty-front**
- New `SignInUpStep.WorkspaceCreation` step +
`useWorkspaceSubdomainField` hook (debounced, stale-response-safe;
auto-fills from the name until the user edits it, with a one-click "use
suggested" when taken; ignores Enter during IME composition; surfaces a
clear error if the availability check fails).
- Onboarding modal name pre-filled from the chosen name.
## Testing
- Unit tests: shared slug util, the `useWorkspaceSubdomainField` hook
(real auto-fill/availability flows via `MockedProvider`), and the
workspace-creation component; existing sign-up tests still pass.
- Typecheck, lint, and format green across twenty-shared / twenty-server
/ twenty-front.
## Notes / out of scope
- No DB migration — the `subdomain` column already existed.
- Self-hosted single-workspace sign-up is unchanged; the step is gated
to multi-workspace (global scope).
- Low-priority follow-ups: length bounds on the subdomain / displayName
inputs, and an integration test for the availability query.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
35c2a24afb |
perf(onboarding): compute invite suggestions on-demand (#21696)
## Summary Follow-up to #21640. In production, invite suggestions took ~1 minute to appear because `FetchOnboardingInviteSuggestionsJob` ran on the shared `calendarQueue` behind heavy calendar-sync jobs. - **Drop the background job entirely.** `getInviteSuggestions` now resolves the connected account from the authenticated `@AuthUserWorkspaceId()` and computes suggestions on demand: cache-first, with a bounded calendar fetch + cache write on a miss. Removes the Google/Microsoft enqueues, the `shouldComputeInviteSuggestions` threading through the auth controllers, and the now-unused `shouldComputeInviteSuggestionsOnConnect` / `isOnboardingConnectAccountPending` helpers. - **Prefetch one step earlier.** New `usePrefetchInviteSuggestions` hook fires the query from `CreateProfile` so the server cache is warm by the time the invite step renders. `InviteTeam` switches from `network-only` → `cache-first`. If the profile step is skipped, the invite step still computes on-demand (~1–3s, no queue) — no more minute-long waits. No GraphQL schema change. ## Test plan - [ ] Connect Google calendar in onboarding → invite step renders prefilled teammates with no perceivable wait - [ ] Connect Microsoft calendar in onboarding → same - [ ] Onboard with workspace name already set so profile step is skipped → invite step still prefills (just with a brief on-demand fetch instead of 1 min) - [ ] Connect a non-work-email account → invite step renders empty form (no suggestions) - [ ] `npx nx typecheck twenty-server` ✅ - [ ] `npx nx lint:diff-with-main twenty-server` ✅ - [ ] `npx nx lint:diff-with-main twenty-front` ✅ (changed files clean) - [ ] `google-apis.service.spec.ts` + `microsoft-apis.service.spec.ts` pass https://claude.ai/code/session_019MyY3bfAEij4AwSXMCLtWY --- _Generated by [Claude Code](https://claude.ai/code/session_019MyY3bfAEij4AwSXMCLtWY)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21696?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
b076c35848 |
fix(messaging): pin Google OAuth2 client to native fetch (#21668)
/closes TWENTY-SERVER-HFH <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21668?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
61309c45e6 |
feat(onboarding): prefill the invite step with teammates from the connected calendar (#21640)
## What & why Implements core-team-issues#1414: move the calendar/email connection earlier in onboarding and use the freshly connected calendar to prefill the **Invite your team** step with likely teammates, so users don't start from an empty form. ## Approach Everything is behind the feature flag `IS_ONBOARDING_INVITE_SUGGESTIONS_ENABLED` (off by default). **Reorder** — onboarding becomes `Workspace activation → Connect account → Create profile → Invite team`. Connecting before profile gives the calendar sync a head start; connecting *before* the workspace exists isn't possible (a connected account requires an activated workspace + workspace member + OAuth transient token). Gated in both `OnboardingService.getOnboardingStatus` (backend) and `useSetNextOnboardingStatus` (frontend) so the two agree. **Fast teammate lookup** — on Google/Microsoft connect *during onboarding*, a background job (`FetchOnboardingInviteSuggestionsJob`) runs a single bounded calendar fetch (recent events, attendees inline), keeps same-work-email-domain colleagues (excludes self + aliases; personal mailboxes yield nothing), ranks by meeting frequency, and caches the top 5. The invite step reads the cache via a new `getInviteSuggestions` query and prefills the form — polling briefly while the cache warms, and never overwriting input the user has already typed. Providers: **Google** (Calendar `events.list`) and **Microsoft** (Graph `calendarView`), routed by a `CalendarAttendeesService` dispatcher (mirrors the existing `CalendarGetCalendarEventsService`). Any fetch failure (missing scope, API error) degrades to today's empty form via the orchestrator's best-effort catch. ## How to enable Turn on `IS_ONBOARDING_INVITE_SUGGESTIONS_ENABLED` for a workspace (admin panel). ## Notes - New-workspace creators only (invitees never see the connect/invite steps). Skipping the connect step, or signing up with a personal email, falls back to the current empty form. - The "We found teammates from your calendar" subtitle only shows once suggestions are actually prefilled. - i18n: the new `<Trans>` strings are extracted on merge to `main` by the existing Crowdin workflow. ## Testing - Frontend unit tests for the reorder state machine (both flag states). - `npx nx typecheck` and `npx nx lint:diff-with-main` green for `twenty-front` and `twenty-server`. - Server boots with the new DI wiring (no circular dependency); `getInviteSuggestions` / `InviteSuggestion` present in the live metadata schema. - Not exercised in CI: live Google/Microsoft OAuth end-to-end (requires real accounts + calendar data). https://claude.ai/code/session_019MyY3bfAEij4AwSXMCLtWY --- _Generated by [Claude Code](https://claude.ai/code/session_019MyY3bfAEij4AwSXMCLtWY)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21640?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com> Co-authored-by: neo773 <neo773@protonmail.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> |
||
|
|
5d892bdfd0 |
[WIP] Feat/marketing emails (#21173)
Marketing/campaign emails on top of the emailing-domain (SES) feature:
send a broadcast to a hand-picked list, with per-customer-domain
unsubscribe links and opt-out-only **unsubscribe topics**.
## Model
Standard objects (workspace schema, flat-metadata):
- `messageCampaign` — a campaign send (subject, body template, from
address, status, list, optional unsubscribe topic).
- `messageList` + `messageListMember` — the hand-picked audience (person
↔ list join). A campaign's recipients are its list's members; everyone
is sendable unless suppressed.
Core entities (`core` schema, workspace-scoped — readable by the public
unsubscribe flow without a workspace context):
- `unsubscribeTopic` — an opt-out-only category (name, description,
visibility). There is no opt-in subscription state.
- `messageSuppression` — the single consent store: a row with
`unsubscribeTopicId` NULL is a global block; a row with an
`unsubscribeTopicId` and reason `UNSUBSCRIBE` is a per-topic opt-out.
Two partial unique indexes dedupe global vs per-topic rows (Postgres
treats NULLs as distinct).
- `emailingDomain` — the workspace's SES sending domain,
auto-provisioned when an email channel is added (and cleaned up when its
last channel is removed), with verification status + DNS records.
Campaign messages reuse the existing `message` / `messageThread` /
`messageParticipant` model — one outbound `message` per recipient with a
`deliveryStatus` state machine.
## Sending
- `sendMessageCampaign` resolves the audience **under the caller's
permissions**, creates the campaign, and enqueues a single fan-out job
(the request never materializes per-recipient rows or jobs).
- The fan-out job materializes one QUEUED message per recipient
(deterministic ids → idempotent re-runs, reconciles crash-orphaned rows)
and fans out per-recipient send jobs carrying **only ids**.
- Each send job renders per-recipient `{{variable}}` merge fields and
sends via `EmailingDomainSenderService`, which applies suppression
(global + per-topic) and the unsubscribe footer/headers. Suppressed
recipients are recorded `SKIPPED`.
- The campaign finalizes `SENT`, or `SENT_WITH_ERRORS` if any recipient
terminally failed.
- `previewMessageCampaignAudience` returns a pre-send breakdown (total /
without-email / duplicate / globally-unsubscribed / topic-unsubscribed /
sendable), shown as a hint under the composer pickers.
## Unsubscribe
- Encrypted (AES-256-GCM) token carrying workspaceId, address, optional
`unsubscribeTopicId`, `issuedAt`, and a `preview` flag.
- One-click POST (RFC 8058) + `mailto:` — topic-scoped when the token
carries a topic, global otherwise.
- Preferences page: a checkbox per visible topic (checked = still
receiving); submitting creates per-topic opt-outs for unchecked topics
and lifts re-checked ones (UNSUBSCRIBE only — never
`BOUNCE`/`COMPLAINT`, never a global block).
- A **Preview** action in settings opens the live page via a
preview-claim token; opt-out POSTs are no-ops for preview tokens, so
previewing never mutates state.
- SES webhooks: inbound unsubscribe + outbound bounce/complaint →
suppression (race-safe against at-least-once delivery, with reason
escalation that never downgrades).
- Per-customer unsubscribe hostname (Cloudflare DNS); sends are gated on
it being active, except in LOG/demo mode.
## Architecture
Campaign orchestration, suppression, the sender, the unsubscribe
controller, and the SES webhook handlers live in `src/modules/emailing`
+ `src/modules/messaging-webhooks` (the workspace-feature layer).
`core-modules/emailing-domain` keeps the SES driver, domain
provisioning, the `unsubscribeTopic` / `messageSuppression` core
entities, and the unsubscribe token/hostname plumbing. Domain creation
is validated (`CreateEmailingDomainInput` — domain-format regex,
lowercased) before any value reaches SES or the unsubscribe hostname.
## Frontend
- Campaign composer side panel (from / list / unsubscribe topic /
subject / body) with a live audience-preview hint.
- Email settings: email channels each showing their auto-provisioned
sending domain in a single section (status + DNS records + a "Check
verification" action), plus an **Unsubscribe Topics** section to
create/manage topics and preview the recipient page. A demo-mode banner
is shown when the LOG driver is active.
---------
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
|
||
|
|
06d68f665d |
fix(auth): additional workspace and identity validation in auth flows (#21347)
## What Adds validation across three auth flows so that a session or reset link is consistently scoped to a workspace the authenticated principal belongs to, and to a verified identity. - **Access token** (`jwt.auth.strategy.ts`): when resolving the request's user context, the token's `userWorkspaceId` must belong to the token's `workspaceId` — the same check the application-token path already performs. - **OIDC** (`oidc.auth.strategy.ts`): reject sign-in when the identity provider explicitly reports `email_verified: false`. - **Password reset** (`reset-password.service.ts`): a supplied `workspaceId` is only used when the user is a member of it; otherwise it falls back to the user's own first password-auth-enabled workspace. ## Tests - `jwt.auth.strategy.spec.ts`: rejects an access token whose user workspace belongs to a different workspace than the token; existing mocks updated to carry the cached `workspaceId`. - `oidc.auth.strategy.spec.ts` (new): rejects unverified email; accepts verified and absent-claim cases. - `reset-password.service.spec.ts`: falls back when the supplied `workspaceId` is not one the user belongs to. `tsgo`, `oxlint` and `oxfmt` all clean on the changed files. |
||
|
|
20ccc52424 |
fix(auth): align workspace resolution across SSO and OTP sign-in flows (#21346)
## What
Makes workspace resolution consistent across the SSO, OTP and
login-token sign-in flows — the target workspace is derived from the
authenticated principal rather than from separate request-supplied
values.
- **SSO callback** (`sso-auth.controller.ts`): validates the resolved
workspace against the authenticating identity provider's own workspace,
so every SSO session is scoped to the provider that issued it. The
`workspaceInviteHash` is request-controlled and shouldn't select a
different workspace than the provider.
- **`getAuthTokensFromOTP`** (`auth.resolver.ts`): reuses the shared
`validateWorkspaceAccess` helper already used by
`getAuthTokensFromLoginToken`, so the login token and the
origin-resolved workspace are checked the same way in both flows.
- **SSO enablement** (`auth.service.ts`, `workspace.validate.ts`): SSO
sign-in now checks the workspace operates an active SSO identity
provider, matching how the other providers gate on their per-workspace
settings, instead of treating SSO as unconditionally enabled.
## Tests
- `auth.service.spec.ts`: SSO sign-in throws when the workspace has no
active SSO identity provider; proceeds when it does.
The controller and resolver spec additions were dropped from this PR;
the behaviours below cover them via manual testing on `main`.
`tsgo`, `oxlint` and `oxfmt` all clean on the changed files.
## How to test on main
Check out this branch on top of `main` and exercise each flow against a
multi-workspace setup (workspace **A** and workspace **B**, each on its
own domain).
**1. SSO callback is scoped to the issuing provider**
(`sso-auth.controller.ts`)
- Configure an SSO identity provider (SAML or OIDC) on workspace **A**
and set it to **Active**.
- Start an SSO sign-in for workspace **A**, but tamper with the callback
so the resolved workspace points at **B** (e.g. supply a
`workspaceInviteHash` belonging to **B**).
- Expected: the callback is rejected with `OAUTH_ACCESS_DENIED`
("Identity provider does not belong to this workspace"). A clean
callback that resolves to **A** still completes sign-in.
**2. Inactive SSO provider blocks sign-in** (`auth.service.ts` /
`workspace.validate.ts`)
- Take workspace **A**'s SSO identity provider and set its status to
something other than `Active` (e.g. inactive/draft).
- Attempt SSO sign-in for **A**.
- Expected: sign-in is denied with `OAUTH_ACCESS_DENIED` ("Identity
provider not found"). Flipping the provider back to `Active` lets
sign-in proceed.
**3. OTP login token must match the origin workspace**
(`getAuthTokensFromOTP` in `auth.resolver.ts`)
- Enable two-factor authentication for a user who belongs to workspace
**A**.
- Sign in to obtain a login token scoped to **A**, then call
`getAuthTokensFromOTP` (submit the OTP) from workspace **B**'s
origin/domain.
- Expected: the request is rejected with `FORBIDDEN_EXCEPTION` ("Token
is not valid for this workspace") and no tokens are issued. Submitting
the OTP from **A**'s origin issues tokens as before.
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
|
||
|
|
91f2f08995 |
feat(server): unify workspace-event ingestion behind one EventSink pipeline (#21197)
## Why The five event-log streams (`workspaceEvent`, `pageview`, `objectEvent`, `usageEvent`, `applicationLog`) each wrote to ClickHouse through their own fire-and-forget writer (`AuditService`, `UsageEventWriterService`, and the `application-logs` driver), with the per-type knowledge (table names, normalization, access rules) spread across several modules. Three of them reimplemented the same ClickHouse insert, and the read side, the live stream, and the producers lived in different modules under two different names. This consolidates them into one `core-modules/event-logs/` subsystem (emit, write, live, read), with the per-type config in a single registry so adding an event type is roughly one file. The base Logs settings tab and free application logs shipped separately in #21180 (merged). This PR adds the unified backend, the registry, and the viewer's live mode and entitlement gating. ## Pipeline ```mermaid flowchart TB subgraph PROD["Producers"] A["auth, billing, impersonation,<br/>webhook, custom-domain"] U["usage listener"] F["logic-function executor (app logs)"] R["record CRUD (entity events)"] end EM["EventLogEmitterService<br/>createContext().insert* / dispatch()"] EQ(["entityEventsToDbQueue<br/>(existing, shared with timeline)"]) CIE["CreateEventLogFromInternalEvent"] SINK["WorkspaceEventSinkService.ingest()"] C1["ClickHouseEventSink"] C2["ConsoleEventSink"] LIVE["EventLogLiveService.publishWatched()<br/>(presence-gated)"] CH[("ClickHouse, 5 tables, async_insert")] CHAN(["WORKSPACE_EVENTS_CHANNEL"]) RS["EventLogsService (registry-driven read)"] LR["EventLogsLiveResolver"] UI["Settings > Logs"] A --> EM U --> EM F --> EM EM -->|direct| SINK R --> EQ --> CIE -->|ingest| SINK SINK --> C1 --> CH SINK --> C2 SINK --> LIVE -.->|if a viewer is watching| CHAN --> LR --> UI CH --> RS --> UI ``` ## What it does - Producers call `EventLogEmitterService.createContext().insert*()`, which builds a typed `WorkspaceEventEnvelope` and writes it through `WorkspaceEventSinkService` to the configured sinks (ClickHouse, Console) plus a presence-gated live fan-out. Record/CRUD events reach the same sink through the existing `entityEventsToDbQueue`. There is no dedicated queue; ClickHouse `async_insert` batches server-side. Writes are best-effort, as on main today. - `EVENT_LOG_TYPES[table]` is the per-type source of truth: the ClickHouse table, the required entitlement, the free-text filter column, and the row-to-GraphQL mapping. Read row shapes derive from the write rows. - Four modules along their dependency boundaries: `EventLogEmitterModule` (producer API), `EventLogIngestionModule` (sink layer), `EventLogLiveModule` (fan-out), and `EventLogsViewerModule` (the entitlement-gated GraphQL read, which is where billing/enterprise/permissions stay so producers stay light). - Logs viewer: per-table columns, filters (text, date, record), live mode, and an upgrade card that points to Billing on Cloud or the Admin Panel on self-hosted. Application logs are free on every plan; the other four require the `AUDIT_LOGS` entitlement (with a `NO_ENTITLEMENT` fallback to the upgrade card). - Renames `AuditService` to `EventLogEmitterService`, and the generic `Monitoring` event to a typed `Impersonation` event (`level` + `action`). - Removes `UsageEventWriterService`, the `application-logs` driver/module, and `AuditService`'s direct inserts. ## Durability Writes are best-effort, the same as main today (the old writers were fire-and-forget). A dedicated queue was tried mid-PR and removed: `async_insert` already batches server-side, so the queue only added durability, which isn't a requirement right now. The `EventSink` seam keeps a durable transport (e.g. a Redis-Streams buffer) easy to add later without touching producers. ## Out of scope S3 peer sink (seam only), Postgres or any second read path, `ReplicatedMergeTree`, ClickHouse table-schema changes, and the record-data `EVENT_STREAM_CHANNEL` (unchanged, separate concern). ## Testing Unit tests cover the registry definitions and row normalization, the entitlement gating, the envelope builders, and the producers. Integration tests cover the write paths (record create produces an `objectEvent`; the track mutation produces a `workspaceEvent`) and the read/query path across all five tables. Verified with typecheck, lint, a server boot, and GraphQL/SDK codegen. |
||
|
|
c3dd6b25a6 |
fix: use canonical oxlint rule id in lint-disable directives (#21253)
## What Many `oxlint-disable` / `eslint-disable` directives across the repo carry a corrupted rule id — `@typescripttypescript/<rule>` — most likely a find-and-replace accident that mangled the eslint-era `@typescript-eslint/` prefix. oxlint matches disable directives **loosely by rule name**, so these still suppress in practice (not a silent no-op), but the id is malformed and misleading. ## Change Replace them with the **canonical oxlint id** `typescript/<rule>` — matching the plugin name and rule keys declared in `.oxlintrc.json` — **127 files, 262 directives**: | rule | count | | --- | ----- | | `typescript/no-explicit-any` | 250 | | `typescript/ban-ts-comment` | 6 | | `typescript/no-misused-promises` | 4 | | `typescript/no-empty-object-type` | 2 | - `twenty-server`: 122 files - `twenty-front`: 5 files Comment-only — no code or runtime changes. ## Verification `oxlint --type-aware -c .oxlintrc.json` reports **0 warnings / 0 errors** for both `twenty-server` and `twenty-front`. Every changed line is exactly the id correction inside a disable directive (262 insertions / 262 deletions, no collateral edits). > Addresses the cubic review, which flagged that the canonical oxlint id is `typescript/...` (no `@`). Worth noting the original `@typescripttypescript/` was not actually a silent no-op — oxlint matches these directives loosely by rule name — but `typescript/` is the correct, config-aligned id. |
||
|
|
3d49642d12 |
[AUDIT] Run knip over twenty-server (#21159)
# Introduction Run [knip](https://knip.dev/) over twenty-server Used config: ```json { "$schema": "https://unpkg.com/knip@5/schema.json", "workspaces": { "packages/twenty-server": { "entry": [ "src/main.ts", "src/command/command.ts", "src/queue-worker/queue-worker.ts", "src/database/scripts/setup-db.ts", "src/database/scripts/truncate-db.ts", "src/database/clickHouse/migrations/run-migrations.ts", "src/database/clickHouse/seeds/run-seeds.ts", "src/instrument.ts", "lingui.config.ts", "test/integration/graphql/codegen/index.ts", "test/integration/utils/setup-test.ts", "test/integration/utils/teardown-test.ts", "scripts/**/*.ts", "**/*.spec.ts", "**/*.integration-spec.ts" ], "project": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"], "ignore": [ "src/database/typeorm/**/migrations/**", "src/database/typeorm/**/*.entity.ts", "**/*.workspace-entity.ts", "**/logic-function-resource/constants/seed-project/**" ], "ignoreDependencies": ["@types/psl", "@types/aws-lambda"], "ignoreBinaries": ["nest", "lingui", "typeorm"] } } } ``` |
||
|
|
120793f69f |
fix: block self-impersonation in admin panel (#21130)
## Issue - From Settings -> Admin Panel -> Workspace -> Members, impersonating the currently logged-in user still issued an impersonation login token. Token exchange produced invalid impersonation JWTs (`impersonatorUserWorkspaceId === impersonatedUserWorkspaceId`). JWT validation then failed with `User cannot impersonate themselves`, leaving the app in an endless loading state until cookies were cleared. - Closes #21086 ## Approach I was first thinking of to only hide the impersonate button for the logged-in user in the admin, since they can not click what isn’t shown (as I thought it was just a frontend issue). But that was not enough: - The `impersonate` mutation can still be called directly (GraphQL client, scripts, devtools). - Before this fix, the mutation could succeed and only fail later at JWT validation, which led to invalid tokens and a broken session. So the PR does both: - Frontend: hide/disable self-impersonation in the UI and avoid reloading on failed token exchange (UX). - Backend: reject self-impersonation in `ImpersonationService` and at token exchange (enforcement, fail fast before bad tokens). Hiding the button is the right product behavior; the backend change is what makes the rule real and safe. ## How to test Manual: - Log in as a user with admin impersonation. - Go to Settings -> Admin panel -> Workspace -> open your workspace -> members. - Confirm your row has no Impersonate button; other members still do. - Open Admin Panel -> User for yourself -> confirm no impersonate button. - Open Settings -> Members -> your own member profile -> confirm no Impersonate action. - Impersonate another member -> should work as before Automated: `npx jest impersonation.service.spec` ### Before: <img width="830" height="413" alt="Screenshot 2026-06-02 122224" src="https://github.com/user-attachments/assets/46f38a74-8bd6-4ffa-b749-500ce18314f1" /> ### After: <img width="795" height="369" alt="Screenshot 2026-06-02 122333" src="https://github.com/user-attachments/assets/62ece4a8-d38b-4f91-817f-792ff49b146b" /> --------- Signed-off-by: Parship Chowdhury <parshipchowdhury@gmail.com> Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> |
||
|
|
1ba7a3fa54 |
fix: lowercase OAuth handle to prevent duplicate connected accounts (#21120)
Google and Microsoft connect flows stored the provider-returned email verbatim, so a re-auth with different casing (Sam@ vs sam@) missed the existing-account lookup and created a duplicate. Normalize the handle to lowercase at extraction, matching the SSO controller. Note: not doing backfill for now |
||
|
|
75df1f3997 |
chore(settings): address review comments from PR 21072 (#21121)
## Summary Round through bosiraphael's 31 review threads on the merged PR #21072 (discovery hero + ephemeral playground token). The user asked to apply each suggestion only where it adds value, so this PR is split into three buckets. ### Comments (~17 threads) - Tightened security-rationale / CSS-gotcha / API-doc comments to one or two factual lines - Kept (shortened) the comments above `RequireAccessTokenGuard` call sites — without them a future reader could remove the guard and silently reopen the escalation hole - Kept (shortened) the in-memory-only rationale on `playgroundApiKeyState` for the same reason - Kept `flex: 1 + min-height: 0` CSS gotcha on `SubMenuTopBarContainer` — non-obvious and easy to break ### Structure / extraction - Move `WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS` to its own constants file (one-export-per-file) - Split `SettingsAgentToolsTab` and `SettingsAgentToolsTable` across queries/, hooks/, types/, utils/: - `graphql/queries/findManyApplicationsForToolTable.ts` - `graphql/queries/findManyMarketplaceAppsForToolTable.ts` - `hooks/useSettingsAgentToolsTable.ts` (data loading + index merging) - `types/SettingsAgentToolItem|Application|MarketplaceApp` - `utils/getToolApplicationId|getToolLink` - Extract `SettingsAiModelsTab` optimistic mutations into `hooks/useSettingsAiModelsActions` (handleModelFieldChange, handleUseRecommendedToggle, handleModelToggle, handleToggleAllVisibleModels) - Extract `SettingsAI.handleCreateTool` into `hooks/useCreateTool` - Drop unnecessary `useMemo` wrappers on `heroTabs` arrays (SettingsObjects, SettingsLayout) - Simplify `MenuItemToggle` handler in SettingsAgentSkillsTab: `onToggleChange={setShowDeactivated}` (no longer wrapping with arrow + read of stale `!showDeactivated`) ### Hero assets - Replace placeholder `customize-illustration` with per-page exports - Rename `layout/customize-illustration-{light,dark}.png` → `layout/cover-{light,dark}.png` - Add `cover-{light,dark}.png` for **applications** and **members** (they were both pointing at the layout placeholder as a TODO) - Overwrite `data-model/cover-*.png`, `playground/cover-*.png`, `ai/ai-tools-cover-*.png` with the new exports ## Test plan - [ ] `npx nx typecheck twenty-front` ✅ - [ ] `npx nx typecheck twenty-server` ✅ - [ ] `npx nx lint twenty-front` ✅ (oxlint + oxfmt, 0 warnings/errors) - [ ] `/settings/layout`, `/settings/data-model`, `/settings/applications`, `/settings/ai`, `/settings/api-webhooks`, `/settings/members` each render the new hero illustration (light + dark) - [ ] AI tab: tool list still loads, search + Custom/Managed/Standard filters still work, "New Tool" still navigates to detail - [ ] AI tab: Models tab — smart/fast model select, "Use best models only" toggle, per-model checkboxes, toggle-all all still optimistic+revert on error - [ ] Skills tab: "Deactivated" toggle still flips show/hide - [ ] Webhooks table still uses the 1fr 28px grid |
||
|
|
b338a7a1d2 |
feat(settings): discovery hero rollout + ephemeral playground token (#21072)
## Summary
Two intertwined streams of work:
### UI — discovery hero pattern, settings shell, AI/API redesign
- **Generalize `SettingsDiscoveryHeroCard`** and use it on Layout, Data
Model, Apps, AI, API/Webhooks, Members. Drops 4 per-page wrapper files
(`SettingsObjectCoverImage`, `SettingsLayoutCoverImage`,
`SettingsLayoutCustomizeVideoModal`,
`SettingsDataModelVisualizeVideoModal`). Each page now supplies cover
src, modal id, and tab list.
- **Modal**: swap `<video>` placeholder for the Vimeo iframe pattern
from `twenty-docs`, per-tab `vimeoId`. Drop the parallel border-bottom
on the header (TabList draws its own baseline) and the grey background
behind the video. Note: Vimeo's embed allowlist applies — the iframes
load with the correct URL on `localhost` but the player itself requires
the video owner to allow the dev/staging domains in Vimeo settings.
- **AI page** rebuilt into a Cockpit pattern (Overview / Models / Skills
/ Tools / Usage). New `SettingsAiOverviewTab` with default Smart/Fast
pickers, at-a-glance stats, and an MCP signpost that deep-links to
`/settings/api-webhooks#mcp`. System Prompt link moved under Models.
Advanced tab removed.
- **API & Webhooks** now has 4 tabs (Playground / MCP / API Keys /
Webhooks). Hero card above tabs. Playground tab inverted to "Core API" /
"Metadata API" sections, each containing REST + GraphQL cards — schema
is the meaningful axis, protocol is secondary. Hash deep-link sync
delegated to the shared `TabListFromUrlOptionalEffect`.
- **Settings shell**: unified drawer outer padding (kill `isSettings`
branch), extract `CollapsibleNavigationDrawerSection`, add `iconColor`
on settings nav items, fix Exit Settings button alignment, 880px content
cap.
### Backend — strategy C: ephemeral playground token
The legacy paste-your-API-key flow is replaced by an on-demand
short-lived token scoped to the calling user's permissions. No shared
"Playground" API key to manage or revoke.
- New `JwtTokenTypeEnum.PLAYGROUND`. `PlaygroundTokenJwtPayload =
Omit<AccessTokenJwtPayload, 'type' | impersonation fields>` so any
future ACCESS claim flows through automatically.
- `AccessTokenService.generatePlaygroundToken` signs an access-shaped
JWT with `type: PLAYGROUND` and a configurable short TTL. A shared
private `resolveTokenSubject` helper parallelizes the user / workspace /
userWorkspace lookups for both generators.
- `JwtAuthStrategy.validateAccessToken` widened to accept
`AccessTokenJwtPayload | PlaygroundTokenJwtPayload`; impersonation gated
on `payload.type === ACCESS` so the union narrows without `as unknown
as` casts. The two branches in `validate()` collapse into one.
- New `PLAYGROUND_TOKEN_EXPIRES_IN` config var (default `2h`).
- New `generatePlaygroundToken` mutation (`WorkspaceAuthGuard`, no args,
returns `AuthToken`).
- Frontend `useOpenPlayground` hook centralizes mint → atom write →
navigate, with Apollo `onError` snackbar and a "use cached PLAYGROUND
token if still fresh" short-circuit (decodes via `jwt-decode`, checks
both `type` AND `exp`). Old API_KEY tokens left in localStorage from the
prior paste-form flow are rejected on `type` alone and force a re-mint —
this is what was causing the "This API Key is revoked" symptom on stale
browsers.
### Drive-by cleanups
- `PlaygroundToken` DTO removed (identical shape to `AuthToken` already
in use).
- 5 `customize-sidebar.webm` imports and the dead placeholder pipeline
removed.
## Test plan
### Discovery hero
- [ ] `/settings/layout`, `/settings/data-model`,
`/settings/applications`, `/settings/ai`, `/settings/api-webhooks`,
`/settings/members` each render the discovery hero card with its
illustration + play button + tabbed modal
- [ ] Modal tabs show the correct Vimeo embed URL per tab; aspect ratio
stays at 1440/900; no parallel border-bottom jog at the tab baseline
- [ ] AI Overview tab shows Smart/Fast model pickers + stats grid + MCP
signpost card; the MCP card lands on `/settings/api-webhooks#mcp` with
the MCP tab active
### API playground (ephemeral token)
- [ ] With an empty `playgroundApiKeyState` in localStorage, clicking
REST or GraphQL playground card opens the playground and the cached
token has `type: "PLAYGROUND"` with ~2h exp
- [ ] Clicking the card again within the freshness window does **not**
re-mint (`iat` / fingerprint stable across visits)
- [ ] Planting a fake API_KEY-shaped JWT in localStorage and clicking
the card forces a fresh mint (old token rejected on `type`)
- [ ] `GET /rest/companies?limit=1` with the cached token returns 200 +
real data
- [ ] `POST /graphql { __typename }` returns 200
### Settings shell
- [ ] Settings nav matches main app drawer padding; sections collapse;
Exit Settings button aligns with the workspace links above
- [ ] Active nav items have a right-gap (cleaner active state)
- [ ] Content area capped at 880px
### Verify
- [ ] `npx nx typecheck twenty-front` passes
- [ ] `npx nx typecheck twenty-server` passes
- [ ] `npx nx lint:diff-with-main twenty-front` passes
- [ ] `npx nx lint:diff-with-main twenty-server` passes
|
||
|
|
961745e9ba |
Fix latency spike on application lookup (#21042)
Fixes https://discord.com/channels/1130383047699738754/1509645089062781058 Caching application entities to improve authentication latency |
||
|
|
ebfaca5b3d |
EncryptedString PlaintextString branded string types (#21001)
## Summary closes https://github.com/twentyhq/core-team-issues/issues/2464 Introduces compile-time branded types to distinguish encrypted ciphertext from plaintext strings, preventing mix-ups like the one fixed in #20819 — but at the type level rather in addition to the one existing at runtime. ### Branded string primitives - Created `EncryptedString` and `PlaintextString` as hard nominal brands using `z.string().brand(...)`, making them non-assignable to each other or to raw `string` - Created `isEncryptedString` type predicate to narrow `string` to `EncryptedString` based on the `enc:v2:` envelope prefix - Retyped `SecretEncryptionService`: `encryptVersioned` accepts `PlaintextString`, `decryptVersioned` returns `PlaintextString` ### Entity typing - Typed encrypted columns across entities: `SigningKeyEntity.privateKey`, `TwoFactorAuthenticationMethodEntity.secret`, `ApplicationRegistrationVariableEntity.encryptedValue`, `ApplicationVariableEntity.value` - Parameterized JSONB types for connected account connection parameters (`ImapSmtpCaldavParams<Pwd>`) with reusable aliases `EncryptedImapSmtpCaldavParams` / `DecryptedImapSmtpCaldavParams` - Typed DTOs (`CreateApplicationRegistrationVariableInput`, `UpdateApplicationRegistrationVariablePayload`, `UpdateApplicationVariableEntityInput`) with `PlaintextString` ### ApplicationVariable always-encrypt uniformization - Retyped `ApplicationVariableEntity.value` to `EncryptedString | ''` — all values are now encrypted regardless of `isSecret` - Updated `ApplicationVariableEntityService` to always encrypt on write and always decrypt on read - Simplified `UpdateApplicationVariableActionHandlerService` by removing conditional encrypt/decrypt-on-isSecret-toggle logic - Added slow instance command (`2.9.0`) to backfill-encrypt existing `isSecret=false` plaintext rows and tighten the `CHECK` constraint ### ConfigStorageService refactor - Split `convertAndSecureValue` (which used `any`) into two well-typed methods: `convertAndDecrypt` and `convertAndEncrypt` - Introduced `isSensitiveStringValue` type predicate to narrow values before encryption/decryption ### What's next - Typeorm entity derivation to strictly type sitemap configuration as code + handler logic for encryption rotation - https://github.com/twentyhq/core-team-issues/issues/2465 |
||
|
|
eda41b4eba |
feat(ai) - add observability (#20850)
**AI Chat - Tool Executions (counters, tagged with model)** ai-chat/tool-execution-succeeded: number of tool calls invoked by the AI that completed without error ai-chat/tool-execution-failed: number of tool calls invoked by the AI that threw an error **AI Chat - Token Usage (counters, tagged with model)** ai-chat/input-tokens: total input tokens sent to the model across all turns ai-chat/output-tokens: total output tokens generated by the model ai-chat/cache-read-tokens: input tokens served from the model's prompt cache (cheaper) ai-chat/cache-write-tokens: input tokens written into the prompt cache for future reuse **AI Chat - Latency (histograms in ms, tagged with model)** ai-chat/turn-latency-ms: total duration of a full chat turn (from stream start to stream end) ai-chat/step-latency-ms: duration of a single reasoning/tool-call step within a turn ai-chat/ttft-ms: time-to-first-token, i.e. how long until the model starts streaming output **MCP - Tool Executions (counters)** mcp/tool-execution-succeeded: number of MCP tool calls that completed successfully mcp/tool-execution-failed: number of MCP tool calls that threw an error |
||
|
|
323e66433e |
lint: migrate prettier to oxfmt (#20783)
Most changes are `implements` being unwrapped this is not a oxfmt regression Prettier in 3.7 (we're on 3.1) changed this behaviour prettier blog [post](https://prettier.io/blog/2025/11/27/3.7.0#change-18094) This unifies our linting tooling --------- Co-authored-by: github-actions <github-actions@twenty.com> Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
923c3beead |
fix(auth): clarify error when joining a non-active workspace (#20769)
## Summary When an existing user accepts an invite into a workspace whose `activationStatus` is not `ACTIVE` (e.g. `SUSPENDED`, `INACTIVE`, `PENDING_CREATION`), the throw in `throwIfWorkspaceIsNotReadyForSignInUp` returns: > User is not part of the workspace The message describes the symptom (they aren't a member yet) instead of the cause (the workspace can't accept new members), which makes invitees assume their invite is broken when the real issue is the target workspace's state. The sibling branch a few lines above — for brand-new users hitting the same non-ACTIVE workspace — already returns `"Workspace is not ready to welcome new members"`. This PR reuses the same message in the existing-user branch so both paths give a consistent, accurate explanation. Single file, two string literals. ## Test plan - [ ] Sign in via Google with an existing Twenty account, accepting an invite to a `SUSPENDED` workspace → confirm the new message is shown instead of "User is not part of the workspace". - [ ] Confirm the happy path (sign-in to an `ACTIVE` workspace via invite) is unchanged — early-return on `ACTIVE` is untouched. --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
265d2edc83 |
Fix QueryRunnerAlreadyReleasedError in sign-in-up service (#20734)
## Context
When signing up on a new workspace,
`SignInUpService.signUpOnNewWorkspace`
manually drove a transaction with `createQueryRunner` /
`startTransaction` /
`commitTransaction` / `rollbackTransaction` / `release`.
If the underlying Postgres connection dropped mid-transaction
(`idle_in_transaction_session_timeout`, server-side termination), the
`pg`
client's `'error'` event fires. TypeORM's connect-time listener responds
by
calling `release()` on the `QueryRunner`, which sets `isReleased = true`
but
deliberately does **not** touch `isTransactionActive`.
The `catch` branch then hit:
```ts
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction(); // throws QueryRunnerAlreadyReleasedError
}
throw error;
```
Error from Sentry
```typescript
QueryRunnerAlreadyReleasedError: Query runner already released. Cannot run queries anymore.
at PostgresQueryRunner.query (.../PostgresQueryRunner.js:177)
at PostgresQueryRunner.rollbackTransaction (.../PostgresQueryRunner.js:167)
at SignInUpService.signUpOnNewWorkspace (.../sign-in-up.service.js:370)
```
## Changes
Replaced the hand-rolled transaction with
this.dataSource.transaction(...).
TypeORM's built-in wrapper already does what we need:
- starts/commits/rolls back the transaction
- wraps rollback in try { ... } catch { /* ignore */ }, so a connection
drop no longer masks the real error
- releases the QueryRunner unconditionally
## Note
Other fix would have been to do this
```typescript
if (queryRunner.isTransactionActive && **!queryRunner.isReleased**) {
try {
await queryRunner.rollbackTransaction();
} catch {
```
|
||
|
|
6cd069ce40 |
messaging minor perf improvement (#20687)
This PR adds two changes 1. Pass `lite:true` to `ExecuteInWorkspaceContextOptions` introduced in https://github.com/twentyhq/twenty/pull/18376 2. Remove redundant gmail alias call, it adds 300ms every cron job, we only do it once now when user connects, realistically I don't see people changing their aliases every day you only set it up once actual real diff is small, it's just prettier format contributing to diff Objective decrease total time take per job |
||
|
|
6b49a14b9f |
feat(auth): set 50-character maximum length on passwords (#20655)
## Summary - Cap password length at 50 characters in the shared regex used by sign-up, password reset, and password change (both `twenty-front` and `twenty-server`). - Update the user-facing validation message on sign-up and password reset to mention both the 8 min and 50 max bounds. - Extend the `PASSWORD_REGEX` unit test to cover the new upper bound. The cap also prevents unbounded inputs from reaching bcrypt, which silently truncates passwords above 72 bytes and can mask user-visible bugs. ## Test plan - [x] `npx jest src/modules/auth/utils/__tests__/passwordRegex.test.ts` passes (8-char min and 50-char max). - [ ] Sign up with a 51-character password — form rejects with "Password must be between 8 and 50 characters". - [ ] Sign up with an 8–50 character password — succeeds. - [ ] Password reset rejects a 51-character password with the same message. - [ ] Existing users with longer passwords (if any pre-exist) can still sign in (the regex only gates write paths: sign-up, change, reset). |
||
|
|
45bea6f991 |
feat(secret-encryption): drop APP_SECRET from approved-access-domain validation and session cookies (#20580)
## Summary Continues retiring `APP_SECRET` as a hot signing secret (after the TOTP migration in #20577). This PR moves the last two cryptographic uses of `APP_SECRET` off it: 1. **Approved-access-domain validation tokens** — was a one-shot `sha256(JSON.stringify({id, domain, key: APP_SECRET}))` HMAC with no built-in expiry. Now a JWT signed by the workspace `signingKey` with a 7-day expiry and claims bound to `approvedAccessDomainId`, `workspaceId`, and `domain`. 2. **Express-session cookie signing** — was `sha256(APP_SECRET || 'SESSION_STORE_SECRET')`. Now `HKDF(ENCRYPTION_KEY, info='twenty:hmac:v1:session-cookie')` with `FALLBACK_ENCRYPTION_KEY` supported for rotation. ### Approved-access-domain — strict cutover - `ApprovedAccessDomainService.mintValidationToken` issues a JWT via `JwtWrapperService.signAsyncOrThrow` (workspace `signingKey`, asymmetric ES256 with kid-based rotation built in). - `validateApprovedAccessDomain` verifies the JWT, asserts `type === APPROVED_ACCESS_DOMAIN`, cross-checks `claim.approvedAccessDomainId` against the URL's `approvedAccessDomainId`, then re-checks `domain` and `workspaceId` against the stored row. Any failure maps to `APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID`. - **No legacy fallback:** any pending invitation link minted with the old SHA hash will fail validation and must be re-sent. Volume is small and admins can re-issue from settings — this is the cleanest cutover. ### Session cookies — bridged cutover - `resolveSessionCookieSecretsOrThrow` returns an array `[HKDF(ENCRYPTION_KEY), HKDF(FALLBACK_ENCRYPTION_KEY)?, sha256(APP_SECRET || 'SESSION_STORE_SECRET')?]`. - `express-session` signs new cookies with the first secret and verifies against any entry, so in-flight cookies signed under the legacy SHA keep verifying until `maxAge` (30 min) expires. - New `deriveInstanceHmacKey` HKDF utility uses a dedicated `twenty:hmac:v1:` info prefix — distinct from the AEAD subkey prefix `twenty:enc:v2:` — so HMAC and encryption subkeys can never collide for the same raw `ENCRYPTION_KEY`. - TODO comment marks the legacy slot for removal post-2.5. ### Notes on rotation behaviour - Rotating `ENCRYPTION_KEY` while keeping the old value in `FALLBACK_ENCRYPTION_KEY` keeps cookies signed under either key verifying. New cookies sign under the new key. After all in-flight cookies expire (≤30 min), the fallback slot can be dropped from env. - Rotating the workspace `signingKey` (already supported by `JwtKeyManagerService`) keeps already-issued approved-access-domain JWTs verifying via `kid` until their 7-day expiry. ## Test plan - [x] Unit tests for `ApprovedAccessDomainService` cover: happy path, JWT verify failure, wrong token type, JWT id ≠ input id, JWT-claimed domain ≠ row, missing row, already-validated row. - [x] Unit tests for `resolveSessionCookieSecretsOrThrow` cover: throws without keys, primary order (`ENCRYPTION_KEY` → APP_SECRET fallback), `FALLBACK_ENCRYPTION_KEY` placement, empty-string vars treated as unset, legacy slot omitted when `APP_SECRET` missing, HKDF domain separation across purposes. - [x] `nx lint:diff-with-main twenty-server` — clean. - [x] Full test surface across approved-access-domain, secret-encryption, session-storage — 78/78 pass. - [ ] CI green. - [ ] Manual smoke: boot with a dummy `ENCRYPTION_KEY`, confirm sign-in succeeds (session cookie works), create + validate an approved-access-domain end-to-end through the UI. |
||
|
|
0d5617d446 |
chore(server): drop unused postgresCredentials feature (#20573)
## Summary Drops the `postgresCredentials` legacy feature: a never-finished "postgres proxy" that would have let users query their workspace data over a standard Postgres connection. Nothing — frontend, e2e, Zapier, docs, other server code — calls these mutations/query. ## History - **Introduced** June 2024 (#5767, Thomas Trompette) as "first step for creating credentials for database proxy", alongside the Postgres FDW / remote-server work and the custom `twenty-postgres-spilo` image. Planned follow-ups (provisioning a DB on the proxy, mapping users, exposing it as a remote server) never landed. - **Abandoned** January 2026 (#17001, Weiko) when the sibling "remote integration" feature was removed as a BREAKING CHANGE — "not maintained for more than a year and never officially launched". The spilo image was then replaced with vanilla `postgres:16` (#19182, March 2026), retiring the FDW infrastructure entirely. - This PR finishes the cleanup: removes the orphaned module, the `allPostgresCredentials` relation, `JwtTokenTypeEnum.POSTGRES_PROXY` + payload, the reserved metadata keywords, and adds a 2.5.0 fast instance command that drops `core.postgresCredentials` (reversible `down`). Regenerated frontend GraphQL types + SDK metadata client. ## Test plan - [x] `tsgo --noEmit` clean on twenty-server + twenty-front; lint + prettier clean on touched files. - [x] `database:migrate:generate` reports no pending schema diff; server boots and serves the new schema. |
||
|
|
34d9fcaba1 |
chore(auth): drop unused workspacePersonalInviteToken from SSO state (#20557)
## Summary
Pure dead-code removal. The Google and Microsoft SSO strategies have
been packing `workspacePersonalInviteToken` into the OAuth `state` blob
and re-emitting it on `validate()`, but
[`signInUpWithSocialSSO`](packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts)
never destructures or reads it from the user object. The SSO flow
resolves invitations by the IdP-verified email instead:
```ts
const invitation =
currentWorkspace && email
? await this.findInvitationForSignInUp({
currentWorkspace,
email, // ← matched against appToken.context.email
})
: undefined;
```
So the strategy plumbing is write-only and confusing for readers.
Removed from:
-
[`SocialSSOState`](packages/twenty-server/src/engine/core-modules/auth/types/social-sso-state.type.ts)
- `GoogleRequest['user']` and `MicrosoftRequest['user']`
- The `state` JSON in both strategies' `authenticate()`
- The user object in both strategies' `validate()`
No frontend change needed — `useAuth.buildRedirectUrl` still sets the
`inviteToken` query param when a personal invite token is present (used
by other paths), and nothing on the SSO server side was reading it.
The token-based invitation lookup is preserved for the password signup
flow via `auth.resolver.signUp` → `findInvitationForSignInUp({
currentWorkspace, workspacePersonalInviteToken })`. Unrelated,
untouched.
## Test plan
- [x] `npx jest engine/core-modules/auth` (twenty-server) — 26 suites /
178 tests pass.
- [x] `tsgo -p tsconfig.json --noEmit` — no new errors on the touched
files (pre-existing `IS_REST_METADATA_API_NEW_FORMAT_DIRECT` errors on
main are unrelated).
- [x] `oxlint` + `prettier --check` on touched files — clean.
- [ ] Manual smoke: Google sign-in still works (workspace selection /
verify flow unaffected since `workspaceInviteHash`, `workspaceId`,
`action`, `locale`, `billingCheckoutSessionState`, `returnToPath` still
flow correctly).
|
||
|
|
49b9660420 |
fix(auth): preserve returnToPath across Google/Microsoft SSO redirects (#20537)
## Summary Fixes the consent-modal-not-reopening half of [#20535](https://github.com/twentyhq/twenty/issues/20535): when a signed-out user opens an OAuth `/authorize?...` URL (e.g. ChatGPT connecting to `api.twenty.com/mcp`) and signs in with **Google or Microsoft**, the original `/authorize` request was lost and the consent screen never reopened. ### Root cause `PageChangeEffect` already saves the deep link as `returnToPath` (Jotai atom) before navigating to `/welcome`. That atom is in-memory: it survives SPA navigation, and the cross-subdomain workspace hop is handled by `useBuildSearchParamsFromUrlSyncedStates` round-tripping the value through the URL. But the social-SSO path leaves `app.twenty.com` entirely — `app.twenty.com/welcome` → `api.twenty.com/auth/google` → Google → `api.twenty.com/auth/google/redirect` → frontend — so the atom is wiped. None of the existing code paths plumbed `returnToPath` through that hop: - `useAuth.buildRedirectUrl` packed `workspaceInviteHash`/`action`/etc. but not `returnToPath`. - `SocialSSOState` / the Google + Microsoft strategies didn't carry it through the OAuth `state` blob. - `signInUpWithSocialSSO` + `computeRedirectURI` didn't re-emit it on the redirect back to the frontend. The email path worked because all transitions stay on the default frontend domain, so the atom survives until `SignInUpGlobalScopeForm` bakes it into the workspace URL. ### What changed Plumb `returnToPath` through the SSO state the same way `workspaceInviteHash` and `action` already flow: - **Frontend** (`useAuth.buildRedirectUrl`): read `returnToPath` from the Jotai store and append it to `/auth/google` / `/auth/microsoft` when set and structurally valid. - **Server types** (`SocialSSOState`, `GoogleRequest['user']`, `MicrosoftRequest['user']`): add optional `returnToPath`. - **Strategies** (`google.auth.strategy.ts`, `microsoft.auth.strategy.ts`): include `returnToPath: req.query.returnToPath` in the JSON `state` and read it back in `validate`. - **auth.service.ts** (`signInUpWithSocialSSO`, `computeRedirectURI`): forward `returnToPath` on both branches — the multi-workspace redirect to `AppPath.SignInUp?tokenPair=...` and the single-workspace redirect to `<workspace>/verify?loginToken=...`. Validated via a new `isValidReturnToPath` helper so a tampered query value can't become an open-redirect vector. After the round-trip, `useInitializeQueryParamState` rehydrates the atom from the URL and `usePageChangeEffectNavigateLocation` resolves it as the post-auth destination — same mechanism the email path already relied on. Out of scope: the OAuth `resource` parameter handling tracked in [#20296](https://github.com/twentyhq/twenty/issues/20296) is independent and not addressed here. ## Test plan - [x] `npx jest src/engine/core-modules/auth` (twenty-server) — 27 suites / 183 tests pass, including new `is-valid-return-to-path.util.spec.ts`. - [x] `npx jest src/modules/auth` (twenty-front) — 13 suites / 52 tests pass, including two new cases in `useAuth.test.tsx` covering the happy path and the protocol-relative open-redirect guard. - [x] `npx nx typecheck twenty-server` / `twenty-front` — clean. - [x] `npx oxlint` + `prettier --check` on touched files — clean. - [ ] Manual: signed-out user opens `https://app.twenty.com/authorize?client_id=...` → Continue with Google → completes Google → selects workspace → consent screen renders. - [ ] Manual: same flow, single workspace — lands on consent screen directly after Verify. - [ ] Manual: email path still works (regression). - [ ] Manual: tamper `returnToPath=//evil.com` on the `/auth/google` URL → server validation rejects, user lands at default home, not at `evil.com`. E2E note: existing `return-to-path.spec.ts` already covers deep links with query params through the email path. A mock OAuth provider would be needed to cover the SSO path end-to-end; unit coverage stands in for now. |
||
|
|
e0b4c9918b |
feat(twenty-server): introduce ENCRYPTION_KEY env var with versioned envelope (#20528)
## Summary
- Adds `ENCRYPTION_KEY` (primary) and `FALLBACK_ENCRYPTION_KEY`
(decrypt-only fallback for rotation) env vars to twenty-server, with
backward-compatible fallback to `APP_SECRET` when `ENCRYPTION_KEY` is
unset.
- Introduces a versioned ciphertext envelope `enc:v2:<keyId>:<base64>`
using AES-256-GCM with HKDF-SHA256 derived per-context keys. The 8-hex
`keyId` fingerprint lets every row identify which physical key encrypted
it, so rotation routes directly to primary or fallback without trial
decryption; GCM's auth tag gives true integrity (legacy CTR has none).
- Migrates `ConnectedAccountTokenEncryptionService` to the new envelope
and plumbs `workspaceId` through every caller, so per-workspace HKDF
context binds each row to its tenant.
The remaining encryption sites (`jwt-key-manager`, `config-storage`,
`postgres-credentials`, `application-variable`, TOTP) stay on the legacy
unprefixed CTR path and will be migrated in follow-up PRs. The
operator-facing rotation runbook is out of scope here.
### Format details
`enc:v{N}:{keyId}:{base64}` — `N=2` is the only version produced by new
writes (`v1` exists for backward-compatible decryption of existing
connected-account rows). `keyId =
sha256(rawKey).slice(0,4).toString('hex')`. The CHECK constraint on
`core.connectedAccount.{accessToken,refreshToken}` is relaxed from `LIKE
'enc:v1:%'` to `LIKE 'enc:v_:%'` so both versions pass.
### Key resolution
| `ENCRYPTION_KEY` | `FALLBACK_ENCRYPTION_KEY` | `APP_SECRET` | Encrypt
with | Decrypt try order |
|---|---|---|---|---|
| set | set | (any) | `ENCRYPTION_KEY` | match `keyId` → primary →
fallback |
| set | unset | (any) | `ENCRYPTION_KEY` | match `keyId` → primary |
| unset | set | set | `APP_SECRET` | match `keyId` → `APP_SECRET` →
fallback |
| unset | unset | set | `APP_SECRET` | match `keyId` → `APP_SECRET` |
| unset | unset | unset | startup error | n/a |
## Test plan
- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx jest
'secret-encryption|connected-account-token-encryption|connected-account-refresh-tokens|encrypt-connected-account-tokens|connection-provider-oauth-flow'`
— 87 tests pass
- [x] New `secret-encryption.service.versioned.spec.ts` covers: key
resolution table (no-key error, APP_SECRET fallback, ENCRYPTION_KEY
precedence), v2 round-trip with/without workspaceId, GCM tamper
rejection, workspaceId-mismatch rejection, keyId-based primary→fallback
routing, missing-key error names the fingerprint, v1 legacy decryption,
no-prefix legacy decryption, malformed envelope rejection.
- [x] Updated `connected-account-token-encryption.service.spec.ts`
covers workspaceId binding and HKDF context isolation.
- [x] Updated slow instance command spec verifies workspaceId is
threaded through encryption and the relaxed `enc:v_:%` LIKE pattern
matches both v1 and v2.
- [ ] Manual E2E: connect a Gmail account on a freshly deployed instance
with `APP_SECRET` only → confirm `core.connectedAccount.accessToken` is
`enc:v2:<keyId>:<base64>`.
- [ ] Manual E2E: rotate — set `ENCRYPTION_KEY=<new>` and
`FALLBACK_ENCRYPTION_KEY=<old APP_SECRET>`, restart, confirm
pre-rotation rows still decrypt and new rows carry the new `keyId`.
- [ ] Manual E2E: missing key — set `ENCRYPTION_KEY=<new>` without the
fallback, confirm decrypt error names the old `keyId` so the operator
can identify the missing key.
|
||
|
|
643ec121a7 |
[twenty-server] no-misused-promise lint (#20529)
# Introduction
Adding `no-miused-promise` lint rule to the twenty-server
In order to flag such pattern
```ts
// ❌ Flagged — forEach doesn't await the callback
items.forEach(async (item) => {
await process(item);
});
```
## What happened
- Refactored the code-interpreter driver to have a async onResult (
which is also expected by e2b )
- Workspace manager still dirty solution including force cast
- Basic fixes
|
||
|
|
ac653182b2 |
feat(server): migrate all remaining JWT token types to ES256 (#20513)
## Summary Extends the asymmetric signing work from #20467 to cover **every remaining `JwtTokenTypeEnum` value**: `LOGIN`, `WORKSPACE_AGNOSTIC`, `FILE`, `API_KEY`, `APPLICATION_ACCESS`, `APPLICATION_REFRESH`, `APP_OAUTH_STATE`, plus the ACCESS-shaped session token issued by the code interpreter tool. After this PR, every JWT the server signs is ES256 with a `kid` pointing at the current `core."signingKey"` row, while legacy HS256 tokens (no `kid` header) remain verifiable indefinitely through the existing fallback in `JwtWrapperService.resolveVerificationKey`. No new entity / migration / config: this is a pure routing change on top of the infrastructure that already shipped. ## Why `#20467` only flipped `ACCESS` and `REFRESH` to ES256. Every other JWT type was still HS256-signed against the global `APP_SECRET`, which kept the original blast radius (a leaked `APP_SECRET` invalidates *every* JWT type forever). Migrating the rest unifies the sign path on rotatable per-server private keys without forcing any token reissue. ## Mechanical changes ### Sign side (8 services) - `LoginTokenService.generateLoginToken` - `TransientTokenService.generateTransientToken` - `WorkspaceAgnosticTokenService.generateWorkspaceAgnosticToken` - `ApplicationTokenService.signApplicationToken` (`APPLICATION_ACCESS` + `APPLICATION_REFRESH`) - `ApiKeyService.generateApiKeyToken` - `FileUrlService.signFileByIdUrl` / `signWorkspaceLogoUrl` - `ConnectionProviderOAuthFlowService.signState` (`APP_OAUTH_STATE`) - `CodeInterpreterTool.generateSessionToken` Each call site swaps `jwtWrapperService.sign(payload, { secret: generateAppSecret(...), ... })` for `await jwtWrapperService.signAsync(payload, { expiresIn, [jwtid] })`. The `generateAppSecret` calls on the sign side are dropped (verifier-side `generateAppSecret` stays in `resolveVerificationKey` for the HS256 fallback). ### Verifier side - `WorkspaceAgnosticTokenService.validateToken` now goes through `verifyJwtToken` instead of the bespoke `verify({ secret })` path, so new ES256 tokens are accepted while the legacy HS256 fallback inside `resolveVerificationKey` still serves the old shape. - `JwtWrapperService.sign()` is kept (legacy compat / tests) but is now strictly deprecated — there are no remaining production callers. ### Async ripple (`signFileByIdUrl` was synchronous) - `FileUrlService.signFileByIdUrl` and `signWorkspaceLogoUrl` are now `async`; the `signUrl` callback used by `getRecordImageIdentifier` is widened to accept `Promise<string | null>`. - Every direct/indirect caller is updated: admin panel (user lookup + statistics + top workspaces), search service (`computeSearchObjectResults`, `getImageIdentifierValue`), workspace resolver (`logo` resolver, public workspace by domain/id), `WorkspaceMemberTranspiler` (now `async toWorkspaceMemberDto[s]` / `toDeletedWorkspaceMemberDto[s]` / `generateSignedAvatarUrl`), `UserService.loadSignedAvatarUrlsByUserId`, `UserWorkspaceService.castWorkspaceToAvailableWorkspace`, workspace-invitation, approved-access-domain, agent-chat-streaming, agent-message-part resolver, navigation-menu-item record identifier, file-ai-chat / file-core-picture / file-email-attachment / file-workflow / files-field services, rich-text & files-field query result getters, and the code-interpreter tool. ## Backward compatibility - **Legacy HS256 tokens (no `kid`)** keep verifying via `resolveVerificationKey` → `extractAppSecretBody` → `generateAppSecret` for both `workspaceId`-bearing and `userId`-bearing payloads. - The `API_KEY` HS256-via-ACCESS-secret fallback (#16504) still kicks in inside `verifyJwtToken` for pre-2025-12-12 API keys. - No payload shape changes, no DB writes, no env var changes — old tokens issued by `main` continue to authenticate. ## Tests ### Unit (all green locally — 63/63) Updated specs for every migrated service to mock `signAsync` instead of `sign` and assert the new option shape: - `login-token.service.spec.ts`, `transient-token.service.spec.ts`, `workspace-agnostic-token.service.spec.ts`, `application-token.service.spec.ts`, `api-key.service.spec.ts`, `connection-provider-oauth-flow.service.spec.ts`. ### Integration (`jwt-key-rotation.integration-spec.ts`) - Existing ACCESS coverage (current key, legacy HS256 fallback, rotated-out key, revoked key, unknown kid) is preserved. - New `it.each` assertion: `REFRESH`, `WORKSPACE_AGNOSTIC`, and `LOGIN` tokens emitted by the real signUp → signUpInNewWorkspace → getAuthTokensFromLoginToken pipeline are ES256 with a `kid` matching the current signing key — proves end-to-end that the migration didn't regress those flows. ## Open question (separate decision) This PR keeps the legacy HS256 verification fallback **forever**. We may eventually want to sunset it for `API_KEY` once telemetry shows pre-migration tokens are gone, but that's a separate product/security decision and not part of this change. ## Test plan - [ ] CI green - [ ] `npx nx lint:diff-with-main twenty-server` passes - [ ] `npx nx typecheck twenty-server` passes - [ ] `jwt-key-rotation` integration suite passes (new + existing assertions) - [ ] Manually verify: signing in issues an ES256 ACCESS / REFRESH token, generating an API key issues an ES256 token with `kid`, signed file URL JWT is ES256 with `kid` - [ ] Pre-existing HS256 tokens still authenticate (covered by integration test, but worth a manual check with a token from `main`) |
||
|
|
a159a68e2c |
[twenty-server] no floating promises lint rule (#20499)
## Introduction
That's an audit + RFC
## Fire-and-forget (`void`) -- Intentional, correct
These are telemetry, metrics, and audit logging in hot paths or
non-critical contexts. `void` is the right choice.
| File | What was voided |
|---|---|
| `sign-in-up.service.ts` | `metricsService.incrementCounter` (sign-up
metric) + `auditService.insertWorkspaceEvent` (workspace created) |
| `use-graphql-error-handler.hook.ts` | 5x
`metricsService.incrementCounter` (GraphQL operation metrics) |
| `bullmq.driver.ts` | 2x `metricsService.incrementCounter` (job
completed/failed metrics) |
| `call-webhook.job.ts` | 2x `auditService.insertWorkspaceEvent` + 1x
`metricsService.incrementCounter` |
| `custom-domain-manager.service.ts` | `analytics.insertWorkspaceEvent`
(domain activation event) |
| `logic-function-executor.service.ts` |
`auditService.insertWorkspaceEvent` (function execution) |
| `workflow-runner.workspace-service.ts` |
`metricsService.incrementCounter` (throttle metric) |
| `cleaner.workspace-service.ts` | `metricsService.incrementCounter`
(deleted workspace metric) |
| `stream-agent-chat.job.ts` | Detached IIFE for streaming chunks
(intentional concurrent pipeline) |
| `workspace-auth-context.middleware.ts` |
`withWorkspaceAuthContext(...)` (AsyncLocalStorage, returns void anyway)
|
## Top-level script entry points (`void bootstrap()`)
These are module-level calls where the promise has no consumer. `void`
makes the lint rule happy and documents the intent.
| File | What changed |
|---|---|
| `main.ts` | `void bootstrap()` |
| `command.ts` | `void bootstrap()` |
| `queue-worker.ts` | `void bootstrap()` |
| `truncate-db.ts` | `void dropSchemasSequentially()` |
| `codegen/index.ts` | `void generateTests(forceArg)` |
## Now properly awaited -- Real bug fixes
These were floating promises that could silently fail, lose data, or
cause race conditions.
| File | What was fixed |
|---|---|
| `billing-sync-plans-data.command.ts` | `meters.map(async ...)` wrapped
in `Promise.all` -- was returning before upserts finished |
| `cache-storage.service.ts` | `setAdd` and `setPop` had `.then()`
chains that weren't returned/awaited |
| `create-audit-log-from-internal-event.ts` | 4x
`auditService.createObjectEvent` now awaited inside a job |
| `cleaner.workspace-service.ts` | 2x `emailService.send(...)` now
awaited -- emails could silently fail |
| `agent-async-executor.service.ts` | `calculateAndBillUsage` +
`billNativeWebSearchUsage` in `finally` block now awaited |
| `repair-tool-call.util.ts` | `calculateAndBillUsage` now awaited |
| `agent-title-generation.service.ts` | `calculateAndBillUsage` now
awaited |
| `chat-execution.service.ts` | `billNativeWebSearchUsage` now awaited |
| `ai-generate-text.controller.ts` | `calculateAndBillUsage` in
`finally` block now awaited |
| `agent-turn.resolver.ts` | `messageQueueService.add(...)` now awaited
|
| `command.ts` | `app.close()` now awaited (was exiting before graceful
shutdown) |
| `i18n.service.ts` | `loadTranslations()` in `onModuleInit` now awaited
|
| `workspace-query-hook.explorer.ts` | `explore()` in `onModuleInit` now
awaited |
| `message-queue.explorer.ts` | `handleProcessorGroupCollection` in
`onModuleInit` now awaited |
| `ai-billing.service.spec.ts` | Test now properly `await`s the async
call |
| `messaging-messages-import.service.spec.ts` | `expect(...)` now
properly `await`ed for async assertion |
| `archive.finalize()` (3 files) | Voided -- promise resolution already
handled by `pipeline()` / `on('end')` |
## Impersonation & security audit trail -- Upgraded from `void` to
`await`
These were previously fire-and-forget but are
security/compliance-critical events that must be reliably persisted.
| File | What was fixed |
|---|---|
| `impersonation.service.ts` | 4x `auditService.insertWorkspaceEvent`
now awaited (impersonation attempt, token generation
attempt/success/failure) |
| `auth.resolver.ts` | 5x `auditService.insertWorkspaceEvent` now
awaited (impersonation token exchange attempt/success/failure at server
and workspace levels) |
| `auth.service.ts` | 2x `analytics.insertWorkspaceEvent` now awaited
(impersonation attempted/issued) |
## Billing audit -- Upgraded from `void` to `await`
Payment events should be reliably persisted for financial/compliance
reporting.
| File | What was fixed |
|---|---|
| `billing-webhook-invoice.service.ts` |
`auditService.insertWorkspaceEvent(PAYMENT_RECEIVED_EVENT)` now awaited
inside Stripe webhook handler |
## Fire-and-forget with proper error handling -- Upgraded from bare
`void`
These remain non-blocking but now catch and log errors instead of
risking unhandled rejections.
| File | What was fixed |
|---|---|
| `logic-function-executor.service.ts` |
`applicationLogsService.writeLogs` now uses `.catch()` instead of bare
`void` -- user-facing logs should surface errors |
## Systemic infrastructure fixes
| File | What was fixed |
|---|---|
| `metrics.service.ts` | `incrementCounter`: Redis cache write
(`metricsCacheService.updateCounter`) now uses `.catch()` internally
instead of raw `await` -- prevents unhandled rejections across all `void
metricsService.incrementCounter(...)` call sites when Redis is unhealthy
|
| `audit.service.ts` | `preventIfDisabled`: made properly `async` with
`await` and consistent `Promise<{ success: boolean }>` return type.
Removed broken `catch` that returned an `AuditException` as a value
(wrong constructor args, unreachable dead code). Removed unused
`AuditException` import |
## Fixed in this session (beyond original PR)
| File | What changed |
|---|---|
| `telemetry.listener.ts` | Removed misleading `Promise.all` + `void`
combo; replaced with simple `for...of` + `void` |
| `message-queue.explorer.ts` | Changed from `void` to `await` so
startup crashes on registration failure |
|
||
|
|
9e515afb13 |
feat(server): asymmetric JWT signing with kid + key rotation table (#20467)
## Context Today every JWT issued by Twenty (access, refresh, login, file, etc.) is HMAC-signed with a per-token-type secret derived from the global `APP_SECRET`. Rotating that secret invalidates **every** active token at once and there is no way to scope a leak to a subset of tokens. This PR is the first slice of a broader effort to **decouple stateful encryption (`APP_SECRET`-derived secrets) from stateless encryption (JWTs)**. It introduces an asymmetric (private/public key) signing path for `ACCESS` and `REFRESH` tokens and a signing-key registry to enable **safe rotation**: leaked keys can be revoked by flipping `revokedAt`/`isCurrent` on the matching row without invalidating tokens issued by other keys. > Out of scope (intentionally): swapping stateful encryption for `APP_SECRET`, asymmetric signing for token types other than `ACCESS`/`REFRESH`, an admin-panel rotation UI, and an enterprise re-encryption command. Those will land in follow-up PRs. ## What changes - **New `core.signingKey` table** (instance command `2.5.0` / `1778550000000`) storing both the public key (PEM, in clear) and the private key (PEM, encrypted with `APP_SECRET` via `SecretEncryptionService`). One row is marked `isCurrent = true` (enforced by a partial unique index). The row's UUID `id` is used directly as the JWT `kid`. - When a key is rotated out, its `privateKey` is nulled (we never keep historical private keys) but the `publicKey` row stays so previously issued tokens can still be verified. - **`JwtKeyManagerService`** lazily loads-or-generates the current signing key on first use: - If a row with `isCurrent = true` exists → decrypts and uses it. - Otherwise → generates a fresh EC P-256 keypair, encrypts the private key, inserts the row (UUID id = kid). Handles concurrent insert races via the unique constraint. - **`JwtWrapperService.signAsync()`** signs `ACCESS`/`REFRESH` payloads with `ES256` and a `kid` header. Falls back to `HS256` if no signing key is available (boot-time DB error, transient failure). - **Dual-path verification** in both `JwtWrapperService.verifyJwtToken` and the Passport `JwtAuthStrategy.secretOrKeyProvider`: - JWT with a `kid` header → resolve the public key PEM by id and verify with `ES256`, - otherwise → fall back to the existing `APP_SECRET`-derived `HS256` path (unchanged). - **`AccessTokenService` / `RefreshTokenService`** now sign through `signAsync` (single public surface; the routing detail stays internal to the wrapper). - **Public key cache**: a new `SigningKeyEntityCacheProviderService` plugs into `CoreEntityCacheService` (`signingKeyPublicKey` namespace) and serves PEMs by id, with the standard local-memo + Redis layering. - **PEM strings end-to-end**: `jsonwebtoken` accepts PEM strings directly for both sign and verify, so the manager never converts to a Node `KeyObject` and the cache hands the PEM straight to `jwt.verify`. ## Why ES256 (and not EdDSA / RS256) - `@nestjs/jwt` is backed by `jsonwebtoken`, which does **not** support EdDSA today. - ES256 keys are tiny (~120 bytes vs 1.6 kB for RS256), signatures are short (~64 bytes), and signing/verification is fast — important since JWT verification runs on every authenticated request. - ES256 is widely supported and standardized (RFC 7518), with mature ecosystem support. ## Why store the private key in DB (not env) - No new secret to provision: existing instances already have `APP_SECRET`, which we reuse to encrypt the private key at rest. - Self-healing: a fresh instance auto-generates its first signing key on first boot. Nothing to copy/paste. - Rotation is a SQL operation against `core.signingKey`, not a redeploy + env mutation. ## Backward compatibility - All previously-issued tokens (no `kid`) keep verifying through the legacy HS256 path with their existing `APP_SECRET`-derived secret. No forced re-login. - Token types not in scope (`WORKSPACE_AGNOSTIC`, `API_KEY`, `FILE`, `LOGIN`, `EMAIL_VERIFICATION`, etc.) keep their current HS256 behavior unchanged — they still go through the synchronous `JwtWrapperService.sign(payload, options)` with a caller-supplied secret. - `signWithAppSecret` is kept intentionally as the HS256 fallback path; it will be deprecated in a follow-up PR. - If the DB lookup/generation fails for any reason, the wrapper logs the error and falls back to HS256 — no startup crash, no silent regression. ## Rotation story 1. Bootstrap: first signing call lazily inserts a row in `core.signingKey` with `isCurrent = true`, `privateKey = encrypt(pem_A)`. New tokens carry `kid_A`. 2. Rotate: `UPDATE core."signingKey" SET "isCurrent" = false, "privateKey" = NULL WHERE id = '<kid_A>';` then insert a new row with `isCurrent = true`. New tokens carry `kid_B`. Tokens still in flight with `kid_A` keep verifying because the public-key row for `kid_A` is still there. 3. Revoke: `UPDATE core."signingKey" SET "revokedAt" = now() WHERE id = '<kid_A>';`. All tokens with `kid_A` now fail verification cleanly with `UNAUTHENTICATED` (no 500). 4. Tokens with no `kid` (legacy) are unaffected throughout. ## Test plan - [x] Unit: `JwtWrapperService` dual-path verification (HS256 no-kid vs ES256 with-kid), unknown-kid → `UNAUTHENTICATED`, `signAsync` happy path + `null` when no key, `signAsync` rejection for non-rotatable types. - [x] Unit: `JwtAuthStrategy` `secretOrKeyProvider` dual-path resolution and algorithm validation. - [x] All existing JWT/auth/application unit tests adjusted to the renamed public method. - [x] Integration (`jwt-key-rotation.integration-spec.ts`): - **Happy path**: signed-up user's `ACCESS` token has `alg=ES256` + correct UUID `kid`, the `isCurrent=true` row exists in `core.signingKey`, `getCurrentUser` resolves. - **Legacy fallback**: hand-crafted no-kid HS256 token verifies via the legacy `APP_SECRET`-derived path. - **Previous-key rotation**: token signed by a hardcoded *previous* key whose row is pre-inserted with `privateKey = NULL` (rotated-out) still verifies — proves the leaked-key revocation flow works in both directions. - **Unknown kid**: token signed with an orphan UUID `kid` is cleanly rejected (no 500). - [x] `npx nx typecheck twenty-server` - [x] `npx nx test twenty-server` - [x] `npx nx run twenty-server:lint` |
||
|
|
6c18bacb93 |
Encrypt connected account accessToken and refreshToken (#20441)
# Introduction Encrypt the `connectedAccount` `accessToken` and `refreshToken` using `APP_SECRET` in order to mitigate potential data leak or `core` table compromise ## Decrypt Temporary allow already plain text stored token to be retrieve without decryption until the slow instance has been passed Will uncomment the invariant check in a patch when the instance slow has fully be run ## Standards - Token are encrypted as quickly as possible - A token cannot be written in database non encrypted by mistake using a custom constraint ( `enc:` prefix ) ## What's next We should standardize not managing secret as is in the the services and layer, they should be encrypted on the flight the earliest and should never be logged Will create a dedicated pattern afterwards for `applicationVariables` secrets too --------- Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com> |
||
|
|
9813467cee |
Refactor SAML relayState structure (#20430)
# Introduction Restructure the RelayState and avoid asserting the idp identifier from this opaque blob Inferring the id from the secured validated and signed request params |
||
|
|
459c64f642 |
Prevent non-admin users from impersonating admin users (#20412)
## Summary - Adds a privilege check to workspace-level impersonation: non-admin users can no longer impersonate users who have `canAccessFullAdminPanel` or `canImpersonate` flags - Adds the same check in JWT token validation as defense-in-depth (invalidates existing impersonation sessions targeting admin users) - Adds 3 unit tests covering: non-admin → admin blocked, non-admin → canImpersonate blocked, admin → admin allowed ## Test plan - [x] Unit tests pass (14/14 in `impersonation.service.spec.ts`) - [x] Typecheck passes - [ ] Verify workspace-level impersonation of regular users still works normally - [ ] Verify server-level impersonation by admins is unaffected 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
4da8878697 |
feat: add email forwarding message channel (#19535)
## Summary - Add email forwarding as a new message channel type, allowing users to forward emails from addresses like `support@mycompany.com` into Twenty - Inbound emails arrive via S3 (SES → S3 bucket), are polled by a cron job, parsed, routed to the correct workspace/channel, and persisted as messages - Dedicated settings page at `/settings/accounts/new-email-forwarding` where users provide their source email handle and receive a unique forwarding address - Forwarding channels bypass the IMAP/mailbox sync state machine — they skip cron-driven sync, relaunch, and message-list-fetch lifecycle stages - Forwarding address section shown at the top of the Emails settings page so users can find/copy their addresses after initial setup - Tab names for forwarding channels display the user-provided handle (e.g. `support@mycompany.com`) instead of the internal routing address - Shared utilities extracted from IMAP driver: `extractThreadId`, `extractParticipants`, `extractAddresses` to avoid code duplication - Uses the existing S3 bucket (STORAGE_S3_*) with `inbound-email/` prefix — no separate bucket needed - Feature gated behind `isEmailForwardingEnabled` client config (requires `INBOUND_EMAIL_DOMAIN` + S3 storage) ## New backend modules - `InboundEmailS3ClientProvider` — lazy-initialized S3 client using existing storage config - `InboundEmailStorageService` — S3 operations (get, move to processed/unmatched/failed) - `InboundEmailParserService` — RFC 822 parsing via `postal-mime`, builds `MessageWithParticipants` - `InboundEmailImportService` — orchestrates download → parse → route → persist → archive - `MessagingInboundEmailPollCronJob` — polls S3 `incoming/` prefix, enqueues import jobs - `CreateEmailForwardingChannelInput` DTO — accepts user-provided `handle` ## New frontend components - `SettingsAccountsNewEmailForwardingChannel` — dedicated page with handle input form + forwarding address result - `SettingsAccountsEmailForwardingSection` — forwarding address list on the Emails settings page - `useConnectedAccountHandleMap` — shared hook for account ID → handle lookup - `useCreateEmailForwardingChannel` — mutation hook accepting handle parameter ## Test plan - [x] 17 unit tests for inbound email import service (all outcomes: imported, unmatched, loop_dropped, unconfigured, parse_failed, persist_failed) - [x] 16 tests for `computeSyncStatus` including EMAIL_FORWARDING cases - [x] 11 tests for `extractEnvelopeRecipient` utility - [x] TypeScript typechecks pass for both twenty-server and twenty-front - [x] Lint passes for both packages - [ ] Manual: create forwarding channel, verify forwarding address generated - [ ] Manual: send email to forwarding address, verify it appears in Twenty https://claude.ai/code/session_01KpyF6p4cUEnuaT4h8DP5Pm --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com> Co-authored-by: neo773 <neo773@protonmail.com> |
||
|
|
eddd2c979a |
Add Workspace Created and Payment Received ClickHouse events (#20277)
## Summary - Adds two new workspace audit events for the AARRR funnel tracked in ClickHouse - **Workspace Created**: emitted in `signUpOnNewWorkspace` after the transaction commits, capturing every new workspace creation - **Payment Received**: emitted in `processInvoicePaid` on every Stripe `invoice.paid` webhook, with `stripeInvoiceId`, `amountPaid`, and `billingReason` properties. First payment per workspace can be derived at query time via `min(timestamp)` grouped by `workspaceId` ## Test plan - [x] Verify `Workspace Created` event appears in ClickHouse after signing up on a new workspace - [x] Verify `Payment Received` event appears in ClickHouse after a Stripe `invoice.paid` webhook fires - [x] Confirm no event is emitted if the billing customer cannot be resolved from `stripeCustomerId` - [x] Run existing `SignInUpService` unit tests pass with the new `AuditService` mock Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
e3be1f4971 |
Make ConnectionProvider a true SyncableEntity (#20232)
## Summary PR #20181 left `ConnectionProvider` in the `SyncableEntity` enum but bypassing the standard sync pipeline — manifest sync called the bespoke `ApplicationOAuthProviderService.upsertManyFromManifest()` instead of going through the workspace-migration orchestrator like every other SyncableEntity. Anything that assumed *"all SyncableEntity values flow through the same pipeline"* (dev UI sync tracking, verification tooling) was wrong about ConnectionProvider — that's the inconsistency this PR closes. This PR follows the `.cursor/skills/syncable-entity-*` guides religiously, all six steps. ## What changes **Step 1 — Types & Constants** (`@syncable-entity-types-and-constants`) - Add `connectionProvider` to `ALL_METADATA_NAME` (twenty-shared) - Make `ApplicationOAuthProviderEntity` extend `SyncableEntity` (drops the ad-hoc columns since the base class provides them, adds `deletedAt`, drops the old `(applicationId, universalIdentifier)` unique in favour of SyncableEntity's `(workspaceId, universalIdentifier)`) - `FlatConnectionProvider`, `FlatConnectionProviderMaps`, `FLAT_CONNECTION_PROVIDER_EDITABLE_PROPERTIES`, `UniversalFlatConnectionProvider`, six action types - Register in **all** the central registries: `AllFlatEntityTypesByMetadataName`, `ALL_METADATA_ENTITY_BY_METADATA_NAME`, `ALL_ENTITY_PROPERTIES_CONFIGURATION`, `ALL_MANY_TO_ONE_*`, `ALL_ONE_TO_MANY_*`, `ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION`, `ALL_METADATA_SERIALIZED_RELATION`, `ALL_JSONB_PROPERTIES_WITH_SERIALIZED_RELATION`, `WORKSPACE_CACHE_KEYS_V2` (`flatConnectionProviderMaps`), `METADATA_EVENTS_TO_EMIT` - `case 'connectionProvider':` in seven discriminated-union switches (`derive-metadata-events-*`, `optimistically-apply-*`, `enrich-create-*`) **Step 2 — Cache & Transform** (`@syncable-entity-cache-and-transform`) - `WorkspaceFlatConnectionProviderMapCacheService` (extends `WorkspaceCacheProvider`, decorated with `@WorkspaceCache`, soft-delete-aware) - `fromConnectionProviderEntityToFlatConnectionProvider` util - `fromConnectionProviderManifestToUniversalFlatConnectionProvider` util - `FlatConnectionProviderModule` wires the cache service - Wired the manifest converter into `compute-application-manifest-all-universal-flat-entity-maps` **Step 3 — Builder & Validation** (`@syncable-entity-builder-and-validation`) - `FlatConnectionProviderValidatorService` — never throws, returns error arrays; uses indexed `byUniversalIdentifier` for the (name, applicationUniversalIdentifier) uniqueness check (no `Object.values().find()` on the hot path) - `WorkspaceMigrationConnectionProviderActionsBuilderService` - Registered in both validators-module + builder-module - **Wired into the orchestrator** (the most-commonly-forgotten step per the rule) — constructor inject, destructure `flatConnectionProviderMaps`, `validateAndBuild`, append actions to the final migration **Step 4 — Runner & Actions** (`@syncable-entity-runner-and-actions`) - Three handlers (create / update / delete) using the canonical `WorkspaceMigrationRunnerActionHandler` mixin - Registered in `WorkspaceSchemaMigrationRunnerActionHandlersModule` **Step 5 — Integration** (`@syncable-entity-integration`) - Delete the `upsertManyFromManifest` bypass on `ApplicationOAuthProviderService` - Remove the bypass call from `ApplicationSyncService` — manifest sync now flows through the standard pipeline - Drop `ApplicationOAuthProviderModule` from `ApplicationManifestModule` (no longer needed) - Import `FlatConnectionProviderModule` from `ApplicationOAuthProviderModule` to keep the cache discoverable - 3 new exception codes: `INVALID_CONNECTION_PROVIDER_INPUT`, `CONNECTION_PROVIDER_NOT_FOUND`, `CONNECTION_PROVIDER_NAME_ALREADY_EXISTS` **Migration** - Generated via `database:migrate:generate` (instance command `1777896012579`): drops the old `(applicationId, universalIdentifier)` unique constraint, adds `deletedAt` column, adds the `(workspaceId, universalIdentifier)` unique index that `SyncableEntity` requires. - Verified clean — a second `migrate:generate` pass produces zero drift. **Step 6 — Tests** (`@syncable-entity-testing`) - 3 new specs for the manifest converter (defaults, optional fields, all-fields) - All 32 existing OAuth-provider tests still pass - ConnectionProvider has no end-user GraphQL CRUD (it's manifest-driven only), so the GraphQL integration suite that other SyncableEntities ship doesn't apply here **Codegen** - Regenerated GraphQL artifacts (twenty-front + twenty-client-sdk) against the live schema ## Why this matters Before: - `ConnectionProvider` claimed to be a `SyncableEntity` (in the enum) - But the entity didn't extend `SyncableEntity` - And the manifest sync bypassed the standard pipeline - → Verification tooling, dev UI sync tracking, anything iterating over `ALL_METADATA_NAME` got inconsistent behaviour After: - `ConnectionProvider` is a `SyncableEntity` end-to-end - Single sync path through the workspace-migration orchestrator (same as `agent`, `skill`, `frontComponent`, `webhook`, …) - One mental model ## Out of scope (deliberate) - **Renaming the table** from `applicationOAuthProvider` to `connectionProvider` — the `metadataName` is `connectionProvider` (what consumers see in code); the table name is internal. A rename would balloon this PR with mechanical churn unrelated to the sync-pipeline wiring. Worth doing as a follow-up. - **`applicationVariable` SyncableEntity conversion** — the other manifest-sync holdout. Tracked in #20215. ## Test plan - [ ] Migration up/down clean against fresh DB - [ ] Install an app whose manifest declares connection providers — providers appear in the workspace - [ ] Re-deploy the app with one provider added, one removed, one renamed → all reconciled correctly via the sync pipeline - [ ] Verify the dev-UI sync-tracking page shows ConnectionProvider entries the same way it shows agents/skills/etc - [ ] OAuth flow still works (existing connections, new connections, reconnect, list/get from SDK) — should be unchanged since the runtime code path didn't move 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e6399b180e |
Fix/workspace member avatars 20193 (#20200)
Fixes #20193 **Bug Description:** Previously, workspace member avatars failed to render correctly in table views and relation chips (such as the Account Owner field). While the avatar picker dropdown correctly fetched fresh GraphQL data, table views and chips relied on the cached defaultAvatarUrl or avatarUrl fields, which were frequently resolving to empty strings or failing to parse external OAuth URLs correctly. **Root Cause:** - Empty String Defaults: Deleting an avatar or failing to retrieve one defaulted the database state to an empty string ("") instead of null, which caused frontend image components to break rather than render their fallback states. - Missing Permanent URLs: The WorkspaceMemberTranspiler was strictly expecting internal signed URLs. If an avatar was an external OAuth URL, it incorrectly returned an empty string, breaking SSO profile pictures. - Missing Fallbacks: New users lacked a proper Gravatar fallback assignment upon workspace creation. **Changes Made:** - user-workspace.service.ts: Updated the avatar computation logic during user creation to implement a reliable Gravatar fallback and correctly set missing avatars to null instead of empty strings. Updated the storage to use permanent file URLs. - file-url.service.ts: Implemented a getRawFileUrl method to support rendering permanent, non-expiring file URLs for avatars. - workspace-member-transpiler.service.ts: Refactored the URL transpilation logic to gracefully pass through external OAuth URLs (e.g., Google/Microsoft profile pictures) instead of stripping them. - WorkspaceMemberPictureUploader.tsx: Fixed the frontend removal logic so that deleting a profile picture sets the avatarUrl to null (consistent with the backend) rather than an empty string. **Testing:** - Verified that avatars correctly display in relation chips and table views. - Verified that external OAuth avatars load properly. - Verified that deleting an avatar correctly resets the UI to the fallback initials component. Co-authored-by: Charles Bochet <charles@twenty.com> Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
3c7c62c79f |
fix(server): deduplicate @opentelemetry/api to fix NoopMeterProvider (#20231)
## Summary **All OTel metrics in twenty-server have been silently dropped since April 30.** ### Root cause PR #20149 (`bump @sentry/profiling-node 10.27→10.51`) pulled in `@sentry/node@10.51.0`, which declares `@opentelemetry/api: ^1.9.1` as a **dependency** (not peer). Yarn installed it as a **nested** copy at `1.9.1`, while the hoisted copy stayed at `1.9.0`. At startup in `instrument.ts`: 1. `Sentry.init()` uses the **nested `1.9.1`** to register `trace`, `propagation`, `context` on the OTel global → global version becomes **`1.9.1`** 2. `setGlobalMeterProvider()` uses the **hoisted `1.9.0`** → `registerGlobal` sees version mismatch (`1.9.1` ≠ `1.9.0`) → **silently returns `false`** 3. Global stays `NoopMeterProvider` → every counter, gauge, and histogram in the server is a no-op ### What this PR does 1. **Reverts three troubleshooting PRs** that are no longer needed now that the root cause is identified: - #20230 — heartbeat gauge - #20228 — OTLP export lifecycle logs - #20221 — Sentry revert to 10.27 (which never actually downgraded in `yarn.lock` since `^10.27.0` resolved to `10.51.0`) 2. **Fixes the root cause**: - Root Yarn resolution pinning `@opentelemetry/api` to `1.9.1` → single copy in the entire tree, Sentry and Twenty share the same instance - Named import in `instrument.ts` (`import { metrics as otelMetrics }` instead of default import) as defense-in-depth against CJS interop issues ### Verified on dev cluster Exec'd into the running pod and confirmed: - `@sentry/node` nests `@opentelemetry/api@1.9.1`, hoisted is `1.9.0` - `Sentry.init()` → global version `1.9.1` → `setGlobalMeterProvider` with VERSION `1.9.0` → returns `false` → `NoopMeterProvider` - Same-version registration returns `true` → `MeterProvider` ✓ ## Test plan - [ ] CI passes (lint, typecheck, build) - [ ] Deploy to dev cluster and verify metrics flow to collector - [ ] Confirm `node_modules/@opentelemetry/api/package.json` shows `1.9.1` with no nested copy under `@sentry/` --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
9e94045fa5 |
feat(apps): generic OAuth provider support for app SDK (#20181)
## Summary
App developers can now declare third-party OAuth integrations (GitHub,
Linear, Slack, etc.) in their manifest and the platform handles the full
authorize → callback → token-exchange → refresh → injection lifecycle.
The dev writes ~10 lines of config and reads tokens via
`useOAuth('linear')` inside any logic function.
```ts
// app/src/oauth-providers/linear.ts
export default defineOAuthProvider({
universalIdentifier: '...',
name: 'linear',
displayName: 'Linear',
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
connectionMode: 'per-user',
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
tokenRequestContentType: 'form-urlencoded',
});
// app/src/logic-functions/handlers/...
const { accessToken } = useOAuth('linear'); // throws OAuthNotConnectedError if missing
```
## Architecture
- **Storage**: extends the existing `connectedAccount` table — new
nullable `applicationOAuthProviderId` FK + new `app` value on the
`ConnectedAccountProvider` enum. Existing Google/Microsoft flows are
untouched.
- **OAuth flow**: a single `/apps/oauth/authorize` +
`/apps/oauth/callback` controller pair handles every app provider. State
travels in a JWT signed via the existing `JwtWrapperService` (new
`APP_OAUTH_STATE` token type).
- **Token exchange**: goes through
`SecureHttpClientService.createSsrfSafeFetch()` (so an installed app
can't point `tokenEndpoint` at internal hosts).
- **Refresh**: piggybacks on the existing
`ConnectedAccountRefreshTokensService` dispatch — Google/Microsoft
drivers untouched, new app driver lives engine-side under
`application-oauth-provider/refresh/`.
- **Injection**: the executor injects refreshed tokens as env vars
(`OAUTH_<NAME>_ACCESS_TOKEN`, `_HANDLE`, `_SCOPES`, `_CONNECTED`); the
SDK helpers `useOAuth` / `useOptionalOAuth` read them.
- **Frontend**: auto-rendered "OAuth Connections" section under each
app's settings tab (no custom front component needed). App-managed
connections are filtered out of `/settings/accounts` so the
email/calendar page stays focused.
- **Disconnect**: best-effort revoke against the manifest's
`revokeEndpoint` before deleting the row.
## Reference app
`packages/twenty-apps/internal/twenty-linear/` exercises the full
pipeline:
- `defineOAuthProvider` for Linear
- `POST /linear/create-issue` and `GET /linear/teams` HTTP-route logic
functions
- Vitest tests for the handlers
## Tests
- 14 server-side Jest tests: token-exchange util (form-urlencoded vs
JSON, PKCE, error paths), flow service (authorize URL shape, state
binding, ConnectedAccount upsert on first/reconnect, per-workspace mode,
invalid state)
- 8 app-level Vitest tests: handler error paths, GraphQL request shape,
Linear error propagation
- All 4 packages clean: `npx nx lint:diff-with-main` and `npx tsc
--noEmit`
## Test plan
- [ ] Apply migration on a dev DB: `npx nx run
twenty-server:database:migrate:prod`
- [ ] Regenerate frontend types: `npx nx run
twenty-front:graphql:generate --configuration=metadata`
- [ ] Create a Linear OAuth app at
https://linear.app/settings/api/applications/new with redirect URI
`<SERVER_URL>/apps/oauth/callback`
- [ ] Deploy + install `twenty-linear` on a workspace, paste the Linear
client id/secret into the app's variables
- [ ] Click "Connect Linear" in the app's settings tab → complete OAuth
→ verify `connectedAccount` row created with `provider = 'app'`
- [ ] Trigger `POST /linear/create-issue` with a valid teamId → verify
issue lands in Linear
- [ ] Disconnect → verify the row is deleted and (if Linear's revoke
endpoint is configured in the manifest) the revoke call fires
- [ ] Verify `/settings/accounts` does NOT show the Linear connection —
it appears only under the Linear app's settings tab
## Out of scope (deliberately)
- **Cron + per-user providers**: a cron-triggered function with a
per-user OAuth provider currently returns `CONNECTED=false` (no user
context). The follow-up design is `useOAuthForUser(name,
userWorkspaceId)` paired with a `POST /apps/oauth/connection-token`
endpoint, deferred to keep this PR focused.
- **Token encryption at rest**: tokens stored as plain `varchar`
matching the existing Google/Microsoft pattern. Worth a separate
cross-cutting PR.
- **Manifest endpoint pinning**: a malicious app upgrade could change
`tokenEndpoint` silently. Same trust model as logic-function source code
(which already runs arbitrary server-side); worth tightening across the
whole upgrade pipeline rather than just OAuth.
- **CLI helpers** (`twenty oauth show-callback-url`, `twenty oauth
connect`): manual setup for v1.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
|
||
|
|
ff22988caf |
revert: Sentry #20064 + @sentry 10.27 (prod bisect) (#20221)
## Summary Reverts **#20064** (`feat(sentry): propagate workspace context to all spans`) and downgrades **@sentry** packages from **10.51** back to **10.27** (reversing **#20149**), to validate in production whether recent Sentry/instrumentation changes correlate with OTLP/metrics issues. ## Changes 1. **Revert #20064** — removes `beforeSendSpan` from `instrument.ts`, restores `WorkspaceAuthContextMiddleware` / `BullMQDriver` behavior, and deletes the three `apply-workspace-sentry-*` utils added in that PR. 2. **Sentry versions** — `packages/twenty-server` (`@sentry/nestjs`, `@sentry/node`, `@sentry/profiling-node`) and `packages/twenty-front` (`@sentry/react`) set to `^10.27.0`; `yarn.lock` regenerated via `yarn install`. --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
875795cc30 |
feat(sentry): propagate workspace context to all spans (#20064)
## Summary After the 2026-04-26 token-usage incident, identifying the responsible workspace from a Sentry trace required a Postgres scavenger hunt — Vercel AI SDK auto-instrumentation captures token counts and model name but no twenty-specific identifiers, and that same gap exists for every other auto-instrumented span (HTTP outbound, Postgres queries, GraphQL resolvers, Redis, etc.). This PR plugs that gap globally, not just for AI: - A small utility (`packages/twenty-server/src/engine/core-modules/sentry/utils/sentry-workspace-context.util.ts`) that writes workspace identifiers onto Sentry's active isolation scope as a `twenty` context block plus filterable tags and a `Sentry.setUser` call. - Two hook points covering all server traffic: - **`WorkspaceAuthContextMiddleware`** — already runs after token hydration on the GraphQL, metadata, admin-panel, and REST routes. It now calls the utility once per authenticated request, before delegating to `withWorkspaceAuthContext`. - **`BullMQDriver.work` and `SyncDriver.processJob`** — every queue job now runs inside `Sentry.withIsolationScope` and applies workspace context from `job.data.workspaceId` (skipping silently for system jobs that don't carry one). - A `beforeSendSpan` hook in `instrument.ts` that reads the scope's `twenty` context block back and projects it onto every span as `twenty.workspace.id` and (when available) `twenty.user_workspace.id` — dotted-namespace naming consistent with OTel/Sentry conventions like `user.id` and `http.response.status_code`. Spans without a workspace context (unauthenticated traffic) pass through untouched. ## Why this shape Sentry's docs position `beforeSendSpan` as a per-span hook. The previous iteration set context only at AI-specific call sites, which left non-AI spans (DB queries, outbound HTTP, regular GraphQL queries, workflow steps not touching AI) entirely unenriched. Hooking the two existing global boundaries — auth middleware for HTTP/GraphQL/REST, and the queue driver `work()` callback for background jobs — covers every authenticated span across the app with no per-handler instrumentation. ## What's not in this PR AI-specific identifiers (`twenty.agent.id`, `twenty.thread.id`, `twenty.turn.id`, `twenty.workflow_run.id`) are out of scope here. They're useful additions but require either propagating the IDs through the call stack or a more fine-grained scope (per-step, per-turn) than the request/job boundary, which is best handled in follow-up PRs that target those specific call sites. ## Test plan - [ ] Make any authenticated GraphQL request locally and confirm the resulting span(s) in Sentry carry `twenty.workspace.id` and (for user-authenticated routes) `twenty.user_workspace.id`. - [ ] Make any authenticated REST request and confirm the same. - [ ] Trigger a queue job (chat stream, agent turn evaluation, workflow run, etc.) and confirm spans produced inside the worker carry `twenty.workspace.id`. - [ ] Confirm that DB and outbound HTTP spans produced under the request/job also carry the workspace tag — these previously had no twenty-specific identifiers. - [ ] In the Sentry UI, filter events by the `twenty.workspace.id` tag and confirm matching events appear. ## Notes for review - Sentry init lives in `instrument.ts`, loaded before Nest bootstraps, so `beforeSendSpan` runs outside Nest DI and reads context off the isolation scope rather than holding a service reference. - The middleware change is three lines; the BullMQ wrap is a single `Sentry.withIsolationScope` around the existing job handler body; the SyncDriver wrap mirrors it for the dev/test path. No new modules or DI providers. - The previous iteration's `AiCallContextService` and per-handler `setContext` / `withContext` calls have been removed in favor of these two hooks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1e27c3b621 |
fix: correct sSOService → ssoService camelCase typo (#19845)
## Summary Fixes the pre-existing camelCase typo mentioned in #19839. The injected `SSOService` property was named `sSOService` instead of the correct camelCase `ssoService` across the auth module. This is a straightforward mechanical rename of the property/variable name — no logic changes. > **Bonus: pre-existing typo to fix** — `private sSOService: SSOService` — The variable name is a camelCase typo (`sSOService` instead of `ssoService`). — #19839 ## Changes Renamed `sSOService` → `ssoService` in 7 files: - `auth/guards/oidc-auth.guard.ts` - `auth/guards/saml-auth.guard.ts` - `auth/guards/oidc-auth.spec.ts` - `auth/auth.resolver.ts` - `auth/controllers/sso-auth.controller.ts` - `auth/strategies/saml.auth.strategy.ts` - `sso/sso.resolver.ts` Note: The type `SSOService` (PascalCase class name) is intentionally left unchanged — it will be addressed in the broader SSO acronym PR from #19839. ## Test plan - [ ] Verify `typecheck twenty-server` passes - [ ] Verify existing auth/SSO tests pass Co-authored-by: Abhay <abhayjnayakpro@gmail.com> |
||
|
|
53d22a3b70 |
fix(server): require PKCE code_challenge for public OAuth clients (#19840)
## Summary
OAuth 2.1 and the MCP authorization spec mandate PKCE (S256) for public
clients — clients registered with \`token_endpoint_auth_method=none\`
(no client secret). We advertise \`code_challenge_methods_supported:
[\"S256\"]\` in \`/.well-known/oauth-authorization-server\` but our
\`/authorize\` flow accepted requests from public clients without
\`code_challenge\`.
## Why this was a soft failure today
\`oauth.service.ts:178\` already rejects token exchange when a client
presents neither \`client_secret\` nor \`code_verifier\`:
\`\`\`ts
if (!clientSecret && !storedCodeChallenge) {
return this.errorResponse('invalid_request', 'Either client_secret or
code_verifier (PKCE) is required');
}
\`\`\`
So a public client attempting to bypass PKCE would **eventually** fail —
but only after:
1. Getting a valid authorization code issued at \`/authorize\`
2. Round-tripping the user through consent
3. Trying to exchange the code at \`/token\` and finally getting
rejected
That's a wasted user interaction and a fuzzy spec boundary. This PR
rejects at \`/authorize\` instead, matching the spec's \"MUST require
PKCE for public clients\" expectation.
## Fix
Single check in \`AuthService.generateAuthorizationCode\`:
\`\`\`ts
const isPublicClient = !applicationRegistration.oAuthClientSecretHash;
if (isPublicClient && !codeChallenge) {
throw new AuthException(
\`code_challenge is required for public clients (PKCE S256, per OAuth
2.1)\`,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
\`\`\`
### Why \`!oAuthClientSecretHash\` is the right \"public\" predicate
- Dynamic registration (\`POST /oauth/register\`) hardcodes
\`oAuthClientSecretHash: null\` and rejects any
\`token_endpoint_auth_method != \"none\"\`
(oauth-registration.controller.ts:120-130).
- Confidential clients registered via the workspace settings UI have a
non-null bcrypt hash.
- The same field is already used as the public/confidential gate in
\`validateClient\` and \`validateClientSecret\`.
## Scope
- ✅ Dynamic-registration clients (Claude, other MCP connectors) — MUST
now supply code_challenge. They already do; no behavior change for
conformant clients.
- ✅ The seeded twenty-cli registration — public client, already uses
PKCE. No change.
- ➖ Confidential clients (workspace-admin-registered OAuth apps with a
client_secret) — unaffected, they authenticate at the token endpoint.
## Related
- #19836 — CORS exposes \`WWW-Authenticate\` / \`MCP-Protocol-Version\`
- #19838 — RFC 9728 PRM split + RFC 9207 iss param + \`scope\` in
WWW-Authenticate challenge
## Test plan
- [x] \`tsc --noEmit\` clean on modified file (pre-existing
\`twenty-shared\` dist errors unrelated)
- [ ] Integration-level smoke test after deploy:
\`\`\`bash
# Register a dynamic client (public)
CLIENT_ID=$(curl -s -X POST -H 'Content-Type: application/json' \\
-d
'{\"client_name\":\"pkce-test\",\"redirect_uris\":[\"http://localhost/cb\"]}'
\\
https://<host>/oauth/register | jq -r .client_id)
# Without code_challenge → should now 4xx at /authorize (cannot easily
test outside the React UI,
# but the GraphQL authorizeApp mutation will throw AuthException)
\`\`\`
- [ ] Claude MCP connector still completes OAuth end-to-end (it always
sends code_challenge, so no-op)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
|
||
|
|
be9616db60 | chore: remove draft email feature flag (#19842) | ||
|
|
1d575f0496 |
fix oauth permission check (#19829)
was regressed due to https://github.com/twentyhq/twenty/pull/19441 |
||
|
|
f6423f5925 |
Remove DataSourceService and clean up datasource migration logic (#19532)
## Summary - **Drop the `objectMetadata.dataSourceId` foreign key and index** via a 1-22 fast instance command — column kept nullable for data preservation - **Delete `DataSourceService`, `DataSourceModule`, and `DataSourceException`** — all code now uses `workspace.databaseSchema` directly - **Remove `IS_DATASOURCE_MIGRATED` feature flag** from default flags and all branching logic - **Simplify workspace/object creation pipelines** — `WorkspaceManagerService`, `DevSeederService`, and the object creation action handler no longer route through `DataSourceService` - **Keep `DataSourceEntity` and the `dataSource` table** for historical data — entity stripped of all ORM relations |
||
|
|
d3f0162cf5 |
Remove connected account feature flag (#19286)
Co-authored-by: martmull <martmull@hotmail.fr> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions <github-actions@twenty.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
2ebff5f4d7 |
fix: harden token renewal and soften refresh token revocation (#19175)
## Summary - **Apollo factory** (`apollo.factory.ts`): Bail early with `EMPTY` when no token pair exists so no request is forwarded without credentials. Propagate renewal success/failure as a boolean so failed renewals stop the operation chain instead of forwarding with stale tokens. - **Refresh token service** (`refresh-token.service.ts`): When a revoked refresh token is reused past the grace period, reject only that token instead of mass-revoking all user tokens. The most common cause is a lost renewal response (e.g. navigation during refresh), not actual token theft. This eliminates the "Suspicious activity detected" errors users were seeing. Also switches to `findOneBy` since the `appTokens` relation is no longer needed. ## Test plan - [x] `refresh-token.service.spec.ts` — all 7 tests pass - [ ] Verify login flow: sign in from `app.localhost`, get redirected to workspace subdomain without "Suspicious activity" errors - [ ] Verify token renewal: let access token expire, confirm silent renewal works and operations resume - [ ] Verify concurrent tabs: open multiple tabs, let tokens expire, confirm no mass revocation cascade Made with [Cursor](https://cursor.com) --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> |
||
|
|
e0630b8653 |
Fix TransactionNotStartedError (#19155)
## Context
Checked in the codebase where we are trying to rollback a non-active
transaction.
packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts
seemed to be the only place where it happens.
We could enforce this with a lint rule in the future 🤔
|