b338a7a1d2
## 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
260 lines
7.6 KiB
TypeScript
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);
|
|
}
|
|
}
|