Files
twenty/packages/twenty-server/src/engine/subscriptions/event-stream.service.ts
T
Félix Malfait 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
2026-06-01 14:16:02 +02:00

260 lines
7.6 KiB
TypeScript

import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/serializable-auth-context.type';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { WithLock } from 'src/engine/core-modules/cache-lock/with-lock.decorator';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { EVENT_STREAM_TTL_MS } from 'src/engine/subscriptions/constants/event-stream-ttl.constant';
import {
EventStreamException,
EventStreamExceptionCode,
} from 'src/engine/subscriptions/event-stream.exception';
import {
type EventStreamData,
type RecordOrMetadataGqlOperationSignature,
} from 'src/engine/subscriptions/types/event-stream-data.type';
@Injectable()
export class EventStreamService implements OnModuleInit {
private readonly logger = new Logger(EventStreamService.name);
constructor(
@InjectCacheStorage(CacheStorageNamespace.EngineSubscriptions)
private readonly cacheStorageService: CacheStorageService,
private readonly cacheLockService: CacheLockService,
private readonly metricsService: MetricsService,
) {}
onModuleInit() {
this.metricsService.createObservableGauge({
metricName: 'twenty_event_streams_live_total',
options: { description: 'Current number of live event streams' },
callback: async () => {
return this.getTotalActiveStreamCount();
},
cacheValue: true,
});
}
async getTotalActiveStreamCount(): Promise<number> {
return this.cacheStorageService.scanAndCountSetMembers(
'workspace:*:activeStreams',
);
}
async createEventStream({
workspaceId,
eventStreamChannelId,
authContext,
}: {
workspaceId: string;
eventStreamChannelId: string;
authContext: SerializableAuthContext;
}): Promise<void> {
const key = this.getEventStreamKey(workspaceId, eventStreamChannelId);
const existing = await this.cacheStorageService.get<EventStreamData>(key);
if (isDefined(existing)) {
throw new EventStreamException(
'Event stream already exists',
EventStreamExceptionCode.EVENT_STREAM_ALREADY_EXISTS,
);
}
const streamData: EventStreamData = {
authContext,
workspaceId,
queries: {},
createdAt: Date.now(),
};
await this.cacheStorageService.set(key, streamData, EVENT_STREAM_TTL_MS);
const activeStreamsKey = this.getActiveStreamsKey(workspaceId);
await this.cacheLockService.withLock(async () => {
await this.cacheStorageService.setAdd(
activeStreamsKey,
[eventStreamChannelId],
EVENT_STREAM_TTL_MS,
);
}, activeStreamsKey);
}
async destroyEventStream({
workspaceId,
eventStreamChannelId,
}: {
workspaceId: string;
eventStreamChannelId: string;
}): Promise<void> {
const key = this.getEventStreamKey(workspaceId, eventStreamChannelId);
await this.cacheStorageService.del(key);
const activeStreamsKey = this.getActiveStreamsKey(workspaceId);
await this.cacheLockService.withLock(async () => {
await this.cacheStorageService.setRemove(activeStreamsKey, [
eventStreamChannelId,
]);
}, activeStreamsKey);
}
async getActiveStreamIds(workspaceId: string): Promise<string[]> {
return this.cacheStorageService.setMembers(
this.getActiveStreamsKey(workspaceId),
);
}
async removeFromActiveStreams(
workspaceId: string,
streamIdsToRemove: string[],
): Promise<void> {
if (streamIdsToRemove.length === 0) {
return;
}
const activeStreamsKey = this.getActiveStreamsKey(workspaceId);
await this.cacheLockService.withLock(async () => {
await this.cacheStorageService.setRemove(
activeStreamsKey,
streamIdsToRemove,
);
}, activeStreamsKey);
}
async getStreamsData(
workspaceId: string,
streamChannelIds: string[],
): Promise<Map<string, EventStreamData | undefined>> {
if (streamChannelIds.length === 0) {
return new Map();
}
const keys = streamChannelIds.map((id) =>
this.getEventStreamKey(workspaceId, id),
);
const values = await this.cacheStorageService.mget<EventStreamData>(keys);
const result = new Map<string, EventStreamData | undefined>();
streamChannelIds.forEach((id, index) => {
result.set(id, values[index]);
});
return result;
}
async isAuthorized({
authContext,
streamData,
}: {
authContext: SerializableAuthContext;
streamData: EventStreamData;
}): Promise<boolean> {
if (isDefined(authContext.userWorkspaceId)) {
return (
streamData.authContext.userWorkspaceId === authContext.userWorkspaceId
);
}
if (isDefined(authContext.apiKeyId)) {
return streamData.authContext.apiKeyId === authContext.apiKeyId;
}
return false;
}
@WithLock('eventStreamChannelId')
async addQuery({
workspaceId,
eventStreamChannelId,
queryId,
operationSignature,
}: {
workspaceId: string;
eventStreamChannelId: string;
queryId: string;
operationSignature: RecordOrMetadataGqlOperationSignature;
}): Promise<void> {
const key = this.getEventStreamKey(workspaceId, eventStreamChannelId);
const existing = await this.cacheStorageService.get<EventStreamData>(key);
if (!isDefined(existing)) {
return;
}
existing.queries[queryId] = operationSignature;
await this.cacheStorageService.set(key, existing, EVENT_STREAM_TTL_MS);
}
@WithLock('eventStreamChannelId')
async removeQuery({
workspaceId,
eventStreamChannelId,
queryId,
}: {
workspaceId: string;
eventStreamChannelId: string;
queryId: string;
}): Promise<void> {
const key = this.getEventStreamKey(workspaceId, eventStreamChannelId);
const existing = await this.cacheStorageService.get<EventStreamData>(key);
if (isDefined(existing) && isDefined(existing.queries[queryId])) {
delete existing.queries[queryId];
await this.cacheStorageService.set(key, existing, EVENT_STREAM_TTL_MS);
}
}
async refreshEventStreamTTL({
workspaceId,
eventStreamChannelId,
}: {
workspaceId: string;
eventStreamChannelId: string;
}): Promise<boolean> {
const eventStreamKey = this.getEventStreamKey(
workspaceId,
eventStreamChannelId,
);
const activeStreamsKey = this.getActiveStreamsKey(workspaceId);
const [eventStreamRefreshed, activeStreamsRefreshed] = await Promise.all([
this.cacheStorageService.expire(eventStreamKey, EVENT_STREAM_TTL_MS),
this.cacheStorageService.expire(activeStreamsKey, EVENT_STREAM_TTL_MS),
]);
return eventStreamRefreshed && activeStreamsRefreshed;
}
private getEventStreamKey(
workspaceId: string,
eventStreamId: string,
): string {
return `eventStream:${workspaceId}:${eventStreamId}`;
}
private getActiveStreamsKey(workspaceId: string): string {
return `workspace:${workspaceId}:activeStreams`;
}
async getStreamData(
workspaceId: string,
eventStreamChannelId: string,
): Promise<EventStreamData | undefined> {
const key = this.getEventStreamKey(workspaceId, eventStreamChannelId);
return this.cacheStorageService.get<EventStreamData>(key);
}
}