first commit
This commit is contained in:
24
libs/api/identity/application/README.md
Normal file
24
libs/api/identity/application/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# api-identity-application
|
||||
|
||||
Casos de uso de identidad.
|
||||
|
||||
- **Import**: `@task-stream/api/identity/application`
|
||||
- **Capa**: `application` · **Tags**: `scope:api-identity`, `type:application`
|
||||
|
||||
## Contenido
|
||||
|
||||
- `IdentityService`: punto de entrada para los presenters.
|
||||
- Queries: `ResolveSessionQuery` (token → usuario, con caché), `FindUserQuery`, `FindUsersByIdsQuery`, `SearchUsersQuery`, `GetAuthorizationConfigQuery`.
|
||||
- Commands: `IssueTokenCommand` (login / refresh vía Pireon), `HandleIdentityWebhookCommand`.
|
||||
- Puertos: `IdentityProvider`, `SessionCache`, `UserRegistry`.
|
||||
|
||||
Ver [Autenticación](../../../../docs/05-autenticacion.md).
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-identity-application
|
||||
npx nx test api-identity-application # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/identity/application/eslint.config.mjs
Normal file
3
libs/api/identity/application/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/identity/application/jest.config.cts
Normal file
11
libs/api/identity/application/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-identity-application',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/api/identity/application',
|
||||
};
|
||||
12
libs/api/identity/application/project.json
Normal file
12
libs/api/identity/application/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-identity-application",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/identity/application/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-identity",
|
||||
"type:application"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-identity-application --web",
|
||||
"targets": {}
|
||||
}
|
||||
11
libs/api/identity/application/src/index.ts
Normal file
11
libs/api/identity/application/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export * from './lib/ports/identity-provider.port';
|
||||
export * from './lib/ports/session-cache.port';
|
||||
export * from './lib/ports/user-registry.port';
|
||||
export * from './lib/commands/handle-identity-webhook.command';
|
||||
export * from './lib/queries/resolve-session.query';
|
||||
export * from './lib/queries/user.queries';
|
||||
export * from './lib/identity-application.handlers';
|
||||
export * from './lib/identity.service';
|
||||
export * from './lib/identity-application.module';
|
||||
export * from './lib/commands/issue-token.command';
|
||||
export * from './lib/queries/get-authorization-config.query';
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { SessionCache } from '../ports/session-cache.port';
|
||||
import { UserRegistry } from '../ports/user-registry.port';
|
||||
|
||||
/** Reacts to Pireon webhooks: `user.deactivated` and `token.revoked`. */
|
||||
export class HandleIdentityWebhookCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly event: string,
|
||||
public readonly data: Record<string, unknown>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(HandleIdentityWebhookCommand)
|
||||
export class HandleIdentityWebhookHandler
|
||||
implements ICommandHandler<HandleIdentityWebhookCommand>
|
||||
{
|
||||
private readonly logger = new Logger(HandleIdentityWebhookHandler.name);
|
||||
|
||||
constructor(
|
||||
private readonly cache: SessionCache,
|
||||
private readonly registry: UserRegistry,
|
||||
) {}
|
||||
|
||||
async execute({ event, data }: HandleIdentityWebhookCommand): Promise<void> {
|
||||
const userId =
|
||||
data['user_id'] ??
|
||||
data['userId'] ??
|
||||
(data['user'] as { id?: unknown } | undefined)?.id;
|
||||
if (userId === undefined || userId === null) {
|
||||
this.logger.warn(`Webhook ${event} without user id, ignored`);
|
||||
return;
|
||||
}
|
||||
const id = String(userId);
|
||||
|
||||
switch (event) {
|
||||
case 'user.deactivated':
|
||||
await Promise.all([this.deactivate(id), this.cache.evictUser(id)]);
|
||||
break;
|
||||
case 'token.revoked':
|
||||
// Only token hashes are cached, so drop every session of that user.
|
||||
await this.cache.evictUser(id);
|
||||
break;
|
||||
default:
|
||||
this.logger.debug(`Unhandled identity webhook ${event}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async deactivate(userId: string): Promise<void> {
|
||||
const user = await this.registry.findById(userId);
|
||||
if (!user) return;
|
||||
user.deactivate();
|
||||
await this.registry.upsert(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { TokenGrant, TokenSet } from '@task-stream/api/identity/domain';
|
||||
import { IdentityProvider } from '../ports/identity-provider.port';
|
||||
|
||||
/** Signs a user in (or refreshes the session) against the identity server. */
|
||||
export class IssueTokenCommand extends Command<TokenSet> {
|
||||
constructor(public readonly grant: TokenGrant) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(IssueTokenCommand)
|
||||
export class IssueTokenHandler implements ICommandHandler<IssueTokenCommand> {
|
||||
constructor(private readonly provider: IdentityProvider) {}
|
||||
|
||||
execute({ grant }: IssueTokenCommand): Promise<TokenSet> {
|
||||
return this.provider.issueToken(grant);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IssueTokenHandler } from './commands/issue-token.command';
|
||||
import { GetAuthorizationConfigHandler } from './queries/get-authorization-config.query';
|
||||
import { HandleIdentityWebhookHandler } from './commands/handle-identity-webhook.command';
|
||||
import { ResolveSessionHandler } from './queries/resolve-session.query';
|
||||
import {
|
||||
FindUserHandler,
|
||||
FindUsersByIdsHandler,
|
||||
SearchUsersHandler,
|
||||
} from './queries/user.queries';
|
||||
|
||||
export const IdentityCommandHandlers = [
|
||||
HandleIdentityWebhookHandler,
|
||||
IssueTokenHandler,
|
||||
];
|
||||
export const IdentityQueryHandlers = [
|
||||
GetAuthorizationConfigHandler,
|
||||
ResolveSessionHandler,
|
||||
FindUserHandler,
|
||||
FindUsersByIdsHandler,
|
||||
SearchUsersHandler,
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
import { DynamicModule, Module, Type } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import {
|
||||
IdentityCommandHandlers,
|
||||
IdentityQueryHandlers,
|
||||
} from './identity-application.handlers';
|
||||
import { IdentityService } from './identity.service';
|
||||
|
||||
/**
|
||||
* Use cases of the identity context. The infrastructure module that
|
||||
* implements its ports (IdentityProvider, SessionCache, UserRegistry) is
|
||||
* plugged in by the composition root.
|
||||
*/
|
||||
@Module({
|
||||
imports: [CqrsModule],
|
||||
providers: [
|
||||
IdentityService,
|
||||
...IdentityCommandHandlers,
|
||||
...IdentityQueryHandlers,
|
||||
],
|
||||
exports: [IdentityService],
|
||||
})
|
||||
export class IdentityApplicationModule {
|
||||
static withInfrastructure(
|
||||
infrastructureModule: Type | DynamicModule,
|
||||
): DynamicModule {
|
||||
return {
|
||||
module: IdentityApplicationModule,
|
||||
imports: [infrastructureModule],
|
||||
};
|
||||
}
|
||||
}
|
||||
60
libs/api/identity/application/src/lib/identity.service.ts
Normal file
60
libs/api/identity/application/src/lib/identity.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
AuthorizationConfig,
|
||||
TokenGrant,
|
||||
TokenSet,
|
||||
User,
|
||||
} from '@task-stream/api/identity/domain';
|
||||
import { HandleIdentityWebhookCommand } from './commands/handle-identity-webhook.command';
|
||||
import { IssueTokenCommand } from './commands/issue-token.command';
|
||||
import { GetAuthorizationConfigQuery } from './queries/get-authorization-config.query';
|
||||
import { ResolveSessionQuery } from './queries/resolve-session.query';
|
||||
import {
|
||||
FindUserQuery,
|
||||
FindUsersByIdsQuery,
|
||||
SearchUsersQuery,
|
||||
} from './queries/user.queries';
|
||||
|
||||
/** Entry point of the identity context for presenters (HTTP, GraphQL, WebSocket). */
|
||||
@Injectable()
|
||||
export class IdentityService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
/** Who owns this Pireon access token. Throws InvalidAccessTokenError. */
|
||||
resolveSession(accessToken: string): Promise<User> {
|
||||
return this.queryBus.execute(new ResolveSessionQuery(accessToken));
|
||||
}
|
||||
|
||||
findUser(userId: string, accessToken?: string): Promise<User | null> {
|
||||
return this.queryBus.execute(new FindUserQuery(userId, accessToken));
|
||||
}
|
||||
|
||||
findUsers(userIds: string[]): Promise<User[]> {
|
||||
return this.queryBus.execute(new FindUsersByIdsQuery(userIds));
|
||||
}
|
||||
|
||||
searchUsers(term: string, accessToken?: string, limit = 20): Promise<User[]> {
|
||||
return this.queryBus.execute(
|
||||
new SearchUsersQuery(term, accessToken, Math.min(limit, 100)),
|
||||
);
|
||||
}
|
||||
|
||||
handleWebhook(event: string, data: Record<string, unknown>): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
new HandleIdentityWebhookCommand(event, data),
|
||||
);
|
||||
}
|
||||
|
||||
/** Sign in / refresh through the identity server (the browser cannot call it: no CORS). */
|
||||
issueToken(grant: TokenGrant): Promise<TokenSet> {
|
||||
return this.commandBus.execute(new IssueTokenCommand(grant));
|
||||
}
|
||||
|
||||
authorizationConfig(): Promise<AuthorizationConfig | null> {
|
||||
return this.queryBus.execute(new GetAuthorizationConfigQuery());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
AuthorizationConfig,
|
||||
TokenGrant,
|
||||
TokenSet,
|
||||
User,
|
||||
} from '@task-stream/api/identity/domain';
|
||||
|
||||
export class InvalidAccessTokenError extends Error {
|
||||
constructor(message = 'Invalid or expired access token') {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Outbound port to the external identity server (Pireon OAuth). */
|
||||
export abstract class IdentityProvider {
|
||||
/** Resolves who owns the token. Throws InvalidAccessTokenError when rejected. */
|
||||
abstract whoAmI(accessToken: string): Promise<User>;
|
||||
/** Looks a user up using the caller's token. Returns null if missing or not visible. */
|
||||
abstract findUser(id: string, accessToken: string): Promise<User | null>;
|
||||
abstract listUsers(accessToken: string): Promise<User[]>;
|
||||
/** Exchanges a grant for tokens. Throws InvalidCredentialsError when rejected. */
|
||||
abstract issueToken(grant: TokenGrant): Promise<TokenSet>;
|
||||
/** Null when no OAuth client is configured for browsers. */
|
||||
abstract authorizationConfig(): AuthorizationConfig | null;
|
||||
}
|
||||
|
||||
export class InvalidCredentialsError extends Error {
|
||||
constructor(message = 'Invalid credentials') {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
|
||||
/** Short-lived cache of resolved tokens, so not every request hits the identity server. */
|
||||
export abstract class SessionCache {
|
||||
abstract get(accessToken: string): Promise<User | null>;
|
||||
abstract set(accessToken: string, user: User): Promise<void>;
|
||||
/** Drops every cached session of a user (deactivation, revoked tokens...). */
|
||||
abstract evictUser(userId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
|
||||
/**
|
||||
* Local read copy of the users that have interacted with task-stream.
|
||||
* Used to show names/emails without calling the identity server.
|
||||
*/
|
||||
export abstract class UserRegistry {
|
||||
abstract upsert(user: User): Promise<void>;
|
||||
abstract findById(id: string): Promise<User | null>;
|
||||
abstract findByIds(ids: string[]): Promise<User[]>;
|
||||
abstract search(term: string, limit: number): Promise<User[]>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
|
||||
import { AuthorizationConfig } from '@task-stream/api/identity/domain';
|
||||
import { IdentityProvider } from '../ports/identity-provider.port';
|
||||
|
||||
export class GetAuthorizationConfigQuery extends Query<AuthorizationConfig | null> {}
|
||||
|
||||
@QueryHandler(GetAuthorizationConfigQuery)
|
||||
export class GetAuthorizationConfigHandler
|
||||
implements IQueryHandler<GetAuthorizationConfigQuery>
|
||||
{
|
||||
constructor(private readonly provider: IdentityProvider) {}
|
||||
|
||||
async execute(): Promise<AuthorizationConfig | null> {
|
||||
return this.provider.authorizationConfig();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { User, UserFactory } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
IdentityProvider,
|
||||
InvalidAccessTokenError,
|
||||
} from '../ports/identity-provider.port';
|
||||
import { SessionCache } from '../ports/session-cache.port';
|
||||
import { UserRegistry } from '../ports/user-registry.port';
|
||||
import {
|
||||
ResolveSessionHandler,
|
||||
ResolveSessionQuery,
|
||||
} from './resolve-session.query';
|
||||
|
||||
const users = new UserFactory();
|
||||
const ada = users.create({
|
||||
id: '1',
|
||||
name: 'Ada',
|
||||
email: 'ada@example.com',
|
||||
isActive: true,
|
||||
roles: ['user'],
|
||||
appRoles: ['editor'],
|
||||
});
|
||||
|
||||
function setup(user: User = ada) {
|
||||
const provider = {
|
||||
whoAmI: jest.fn().mockResolvedValue(user),
|
||||
findUser: jest.fn(),
|
||||
listUsers: jest.fn(),
|
||||
};
|
||||
const cached = new Map<string, User>();
|
||||
const cache = {
|
||||
get: jest.fn(async (t: string) => cached.get(t) ?? null),
|
||||
set: jest.fn(async (t: string, u: User) => void cached.set(t, u)),
|
||||
evictUser: jest.fn(),
|
||||
};
|
||||
const registry = { upsert: jest.fn(async (_: User) => undefined) };
|
||||
const handler = new ResolveSessionHandler(
|
||||
provider as unknown as IdentityProvider,
|
||||
cache as unknown as SessionCache,
|
||||
registry as unknown as UserRegistry,
|
||||
);
|
||||
return { handler, provider, registry };
|
||||
}
|
||||
|
||||
describe('ResolveSessionHandler', () => {
|
||||
it('asks Pireon once, then serves from cache', async () => {
|
||||
const { handler, provider, registry } = setup();
|
||||
|
||||
await handler.execute(new ResolveSessionQuery('token'));
|
||||
const again = await handler.execute(new ResolveSessionQuery('token'));
|
||||
|
||||
expect(again).toEqual(ada);
|
||||
expect(provider.whoAmI).toHaveBeenCalledTimes(1);
|
||||
expect(registry.upsert).toHaveBeenCalledWith(ada);
|
||||
});
|
||||
|
||||
it('rejects deactivated users', async () => {
|
||||
const { handler } = setup(
|
||||
users.create({ ...ada.toSnapshot(), isActive: false }),
|
||||
);
|
||||
await expect(
|
||||
handler.execute(new ResolveSessionQuery('token')),
|
||||
).rejects.toThrow(InvalidAccessTokenError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
IdentityProvider,
|
||||
InvalidAccessTokenError,
|
||||
} from '../ports/identity-provider.port';
|
||||
import { SessionCache } from '../ports/session-cache.port';
|
||||
import { UserRegistry } from '../ports/user-registry.port';
|
||||
|
||||
/** Resolves who owns an access token (cache → Pireon). */
|
||||
export class ResolveSessionQuery extends Query<User> {
|
||||
constructor(public readonly accessToken: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(ResolveSessionQuery)
|
||||
export class ResolveSessionHandler
|
||||
implements IQueryHandler<ResolveSessionQuery>
|
||||
{
|
||||
constructor(
|
||||
private readonly provider: IdentityProvider,
|
||||
private readonly cache: SessionCache,
|
||||
private readonly registry: UserRegistry,
|
||||
) {}
|
||||
|
||||
async execute({ accessToken }: ResolveSessionQuery): Promise<User> {
|
||||
const cached = await this.cache.get(accessToken);
|
||||
if (cached) return cached;
|
||||
|
||||
const user = await this.provider.whoAmI(accessToken);
|
||||
if (!user.isActive)
|
||||
throw new InvalidAccessTokenError('User is deactivated');
|
||||
|
||||
await Promise.all([
|
||||
this.cache.set(accessToken, user),
|
||||
this.registry.upsert(user),
|
||||
]);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import { IdentityProvider } from '../ports/identity-provider.port';
|
||||
import { UserRegistry } from '../ports/user-registry.port';
|
||||
|
||||
/**
|
||||
* Finds a user locally, falling back to the identity server with the
|
||||
* caller's token (and remembering the result).
|
||||
*/
|
||||
export class FindUserQuery extends Query<User | null> {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly accessToken?: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class FindUsersByIdsQuery extends Query<User[]> {
|
||||
constructor(public readonly userIds: string[]) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches users. With a token, the identity server directory is synced
|
||||
* first so people who never logged in can be found too.
|
||||
*/
|
||||
export class SearchUsersQuery extends Query<User[]> {
|
||||
constructor(
|
||||
public readonly term: string,
|
||||
public readonly accessToken?: string,
|
||||
public readonly limit = 20,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(FindUserQuery)
|
||||
export class FindUserHandler implements IQueryHandler<FindUserQuery> {
|
||||
constructor(
|
||||
private readonly registry: UserRegistry,
|
||||
private readonly provider: IdentityProvider,
|
||||
) {}
|
||||
|
||||
async execute({ userId, accessToken }: FindUserQuery): Promise<User | null> {
|
||||
const local = await this.registry.findById(userId);
|
||||
if (local || !accessToken) return local;
|
||||
|
||||
const remote = await this.provider.findUser(userId, accessToken);
|
||||
if (remote) await this.registry.upsert(remote);
|
||||
return remote;
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(FindUsersByIdsQuery)
|
||||
export class FindUsersByIdsHandler
|
||||
implements IQueryHandler<FindUsersByIdsQuery>
|
||||
{
|
||||
constructor(private readonly registry: UserRegistry) {}
|
||||
|
||||
execute({ userIds }: FindUsersByIdsQuery): Promise<User[]> {
|
||||
return this.registry.findByIds([...new Set(userIds)]);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(SearchUsersQuery)
|
||||
export class SearchUsersHandler implements IQueryHandler<SearchUsersQuery> {
|
||||
constructor(
|
||||
private readonly registry: UserRegistry,
|
||||
private readonly provider: IdentityProvider,
|
||||
) {}
|
||||
|
||||
async execute({
|
||||
term,
|
||||
accessToken,
|
||||
limit,
|
||||
}: SearchUsersQuery): Promise<User[]> {
|
||||
if (accessToken) {
|
||||
const remote = await this.provider.listUsers(accessToken).catch(() => []);
|
||||
await Promise.all(remote.map((u) => this.registry.upsert(u)));
|
||||
}
|
||||
return this.registry.search(term, limit);
|
||||
}
|
||||
}
|
||||
23
libs/api/identity/application/tsconfig.json
Normal file
23
libs/api/identity/application/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
libs/api/identity/application/tsconfig.lib.json
Normal file
23
libs/api/identity/application/tsconfig.lib.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"],
|
||||
"target": "es2021",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
16
libs/api/identity/application/tsconfig.spec.json
Normal file
16
libs/api/identity/application/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
21
libs/api/identity/domain/README.md
Normal file
21
libs/api/identity/domain/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# api-identity-domain
|
||||
|
||||
El usuario, tal como lo conoce TaskStream (sus datos vienen de Pireon).
|
||||
|
||||
- **Import**: `@task-stream/api/identity/domain`
|
||||
- **Capa**: `domain` · **Tags**: `scope:api-identity`, `type:domain`
|
||||
|
||||
## Contenido
|
||||
|
||||
- `User` (agregado): nombre, email, activo, roles globales y roles en el cliente; `hasAppRole()`, `deactivate()`.
|
||||
- `UserFactory`: construye usuarios a partir de Pireon o de la caché.
|
||||
- Tipos del inicio de sesión: `TokenGrant`, `TokenSet`, `AuthorizationConfig`.
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-identity-domain
|
||||
npx nx test api-identity-domain # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/identity/domain/eslint.config.mjs
Normal file
3
libs/api/identity/domain/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/identity/domain/jest.config.cts
Normal file
11
libs/api/identity/domain/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-identity-domain',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/api/identity/domain',
|
||||
};
|
||||
12
libs/api/identity/domain/project.json
Normal file
12
libs/api/identity/domain/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-identity-domain",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/identity/domain/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-identity",
|
||||
"type:domain"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-identity-domain --web",
|
||||
"targets": {}
|
||||
}
|
||||
4
libs/api/identity/domain/src/index.ts
Normal file
4
libs/api/identity/domain/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './lib/user';
|
||||
export * from './lib/events/user-deactivated.event';
|
||||
export * from './lib/factories/user.factory';
|
||||
export * from './lib/token';
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AutowiredEvent, DomainEvent } from '@task-stream/api/shared/domain';
|
||||
|
||||
/** Pireon deactivated the user. */
|
||||
@AutowiredEvent
|
||||
export class UserDeactivatedEvent extends DomainEvent {
|
||||
constructor(readonly userId: string) {
|
||||
super('identity-server');
|
||||
}
|
||||
}
|
||||
25
libs/api/identity/domain/src/lib/factories/user.factory.ts
Normal file
25
libs/api/identity/domain/src/lib/factories/user.factory.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { assertThat } from '@task-stream/api/shared/domain';
|
||||
import { User, UserSnapshot } from '../user';
|
||||
|
||||
type UserProps = Omit<UserSnapshot, 'roles' | 'appRoles'> &
|
||||
Partial<Pick<UserSnapshot, 'roles' | 'appRoles'>>;
|
||||
|
||||
/** Users are never created here (Pireon owns them): they are only (re)built. */
|
||||
@Injectable()
|
||||
export class UserFactory {
|
||||
create(props: UserProps): User {
|
||||
assertThat(props.id.length > 0, 'User id is required');
|
||||
const user = new User(props.id);
|
||||
user.name = props.name;
|
||||
user.email = props.email;
|
||||
user.isActive = props.isActive;
|
||||
user.roles = [...(props.roles ?? [])];
|
||||
user.appRoles = [...(props.appRoles ?? [])];
|
||||
return user;
|
||||
}
|
||||
|
||||
restore(snapshot: UserProps): User {
|
||||
return this.create(snapshot);
|
||||
}
|
||||
}
|
||||
27
libs/api/identity/domain/src/lib/token.ts
Normal file
27
libs/api/identity/domain/src/lib/token.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** How a client proves who it is to get tokens from the identity server. */
|
||||
export type TokenGrant =
|
||||
| {
|
||||
type: 'authorization_code';
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
| { type: 'password'; username: string; password: string }
|
||||
| { type: 'refresh_token'; refreshToken: string };
|
||||
|
||||
export interface TokenSet {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
/** Seconds until the access token expires. */
|
||||
expiresIn: number;
|
||||
tokenType: string;
|
||||
}
|
||||
|
||||
/** What a browser needs to start the Authorization Code + PKCE flow. */
|
||||
export interface AuthorizationConfig {
|
||||
authorizeUrl: string;
|
||||
clientId: string;
|
||||
scopes: string[];
|
||||
/** Password grant can be disabled on the identity server. */
|
||||
passwordGrant: boolean;
|
||||
}
|
||||
56
libs/api/identity/domain/src/lib/user.ts
Normal file
56
libs/api/identity/domain/src/lib/user.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { VersionedAggregateRoot } from '@task-stream/api/shared/domain';
|
||||
import { UserDeactivatedEvent } from './events/user-deactivated.event';
|
||||
|
||||
export interface UserSnapshot {
|
||||
/** Pireon user id (string to stay provider-agnostic). */
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
isActive: boolean;
|
||||
/** Global roles in the identity server. */
|
||||
roles: string[];
|
||||
/** Roles granted inside this OAuth client (task-stream). */
|
||||
appRoles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A person managed by the Pireon identity server. Not event-sourced here
|
||||
* (Pireon owns users): UserFactory rebuilds it from Pireon or the local cache.
|
||||
*/
|
||||
export class User extends VersionedAggregateRoot implements UserSnapshot {
|
||||
public name!: string;
|
||||
public email!: string;
|
||||
public isActive = true;
|
||||
public roles: string[] = [];
|
||||
public appRoles: string[] = [];
|
||||
|
||||
constructor(id: string) {
|
||||
super();
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/** Global Pireon admins implicitly hold every app role. */
|
||||
hasAppRole(role: string): boolean {
|
||||
return this.appRoles.includes(role) || this.roles.includes('admin');
|
||||
}
|
||||
|
||||
deactivate(): void {
|
||||
if (!this.isActive) return;
|
||||
this.apply(new UserDeactivatedEvent(this.id));
|
||||
}
|
||||
|
||||
onUserDeactivatedEvent(): void {
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
toSnapshot(): UserSnapshot {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
email: this.email,
|
||||
isActive: this.isActive,
|
||||
roles: [...this.roles],
|
||||
appRoles: [...this.appRoles],
|
||||
};
|
||||
}
|
||||
}
|
||||
23
libs/api/identity/domain/tsconfig.json
Normal file
23
libs/api/identity/domain/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
libs/api/identity/domain/tsconfig.lib.json
Normal file
15
libs/api/identity/domain/tsconfig.lib.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
16
libs/api/identity/domain/tsconfig.spec.json
Normal file
16
libs/api/identity/domain/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
21
libs/api/identity/infrastructure/README.md
Normal file
21
libs/api/identity/infrastructure/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# api-identity-infrastructure
|
||||
|
||||
Adaptadores de salida de identidad.
|
||||
|
||||
- **Import**: `@task-stream/api/identity/infrastructure`
|
||||
- **Capa**: `infrastructure` · **Tags**: `scope:api-identity`, `type:infrastructure`
|
||||
|
||||
## Contenido
|
||||
|
||||
- `PireonIdentityProvider`: `GET /api/user`, `/api/users` y `POST /oauth/token` de Pireon.
|
||||
- `RedisSessionCache`: tokens validados, guardados por hash SHA-256 y con TTL.
|
||||
- `MongoUserRegistry`: copia local de usuarios (colección `users`).
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-identity-infrastructure
|
||||
npx nx test api-identity-infrastructure # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/identity/infrastructure/eslint.config.mjs
Normal file
3
libs/api/identity/infrastructure/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/identity/infrastructure/jest.config.cts
Normal file
11
libs/api/identity/infrastructure/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-identity-infrastructure',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/api/identity/infrastructure',
|
||||
};
|
||||
12
libs/api/identity/infrastructure/project.json
Normal file
12
libs/api/identity/infrastructure/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-identity-infrastructure",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/identity/infrastructure/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-identity",
|
||||
"type:infrastructure"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-identity-infrastructure --web",
|
||||
"targets": {}
|
||||
}
|
||||
1
libs/api/identity/infrastructure/src/index.ts
Normal file
1
libs/api/identity/infrastructure/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/identity-infrastructure.module';
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import {
|
||||
IdentityProvider,
|
||||
SessionCache,
|
||||
UserRegistry,
|
||||
} from '@task-stream/api/identity/application';
|
||||
import { UserFactory } from '@task-stream/api/identity/domain';
|
||||
import { MongoUserRegistry } from './mongo/mongo-user.registry';
|
||||
import { UserDocument, UserSchema } from './mongo/user.schema';
|
||||
import { PIREON_CONFIG, PireonConfig } from './pireon/pireon.config';
|
||||
import { PireonIdentityProvider } from './pireon/pireon-identity.provider';
|
||||
import { RedisSessionCache } from './redis/redis-session.cache';
|
||||
|
||||
/** Driven adapters of the identity context: Pireon (HTTP), Redis and MongoDB. */
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: UserDocument.name, schema: UserSchema },
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
UserFactory,
|
||||
{
|
||||
provide: PIREON_CONFIG,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): PireonConfig => ({
|
||||
baseUrl: config.getOrThrow<string>('PIREON_BASE_URL'),
|
||||
sessionTtlSeconds: Number(config.get('AUTH_SESSION_TTL_SECONDS', 60)),
|
||||
requestTimeoutMs: Number(config.get('PIREON_TIMEOUT_MS', 5000)),
|
||||
clientId: config.get<string>('PIREON_CLIENT_ID') || undefined,
|
||||
clientSecret: config.get<string>('PIREON_CLIENT_SECRET') || undefined,
|
||||
scopes: String(config.get('PIREON_SCOPES', 'users:read'))
|
||||
.split(/\s+/)
|
||||
.filter(Boolean),
|
||||
passwordGrant:
|
||||
String(config.get('PIREON_PASSWORD_GRANT', 'true')) !== 'false',
|
||||
}),
|
||||
},
|
||||
{ provide: IdentityProvider, useClass: PireonIdentityProvider },
|
||||
{ provide: SessionCache, useClass: RedisSessionCache },
|
||||
{ provide: UserRegistry, useClass: MongoUserRegistry },
|
||||
],
|
||||
exports: [IdentityProvider, SessionCache, UserRegistry],
|
||||
})
|
||||
export class IdentityInfrastructureModule {}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { UserRegistry } from '@task-stream/api/identity/application';
|
||||
import { User, UserFactory } from '@task-stream/api/identity/domain';
|
||||
import { Model } from 'mongoose';
|
||||
import { UserDocument } from './user.schema';
|
||||
|
||||
@Injectable()
|
||||
export class MongoUserRegistry extends UserRegistry {
|
||||
constructor(
|
||||
@InjectModel(UserDocument.name) private readonly users: Model<UserDocument>,
|
||||
private readonly factory: UserFactory,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async upsert(user: User): Promise<void> {
|
||||
await this.users.updateOne(
|
||||
{ _id: user.id },
|
||||
{ $set: { name: user.name, email: user.email, isActive: user.isActive } },
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
const doc = await this.users.findById(id).lean();
|
||||
return doc ? this.toUser(doc) : null;
|
||||
}
|
||||
|
||||
async findByIds(ids: string[]): Promise<User[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const docs = await this.users.find({ _id: { $in: ids } }).lean();
|
||||
return docs.map((d) => this.toUser(d));
|
||||
}
|
||||
|
||||
async search(term: string, limit: number): Promise<User[]> {
|
||||
const pattern = new RegExp(
|
||||
term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
|
||||
'i',
|
||||
);
|
||||
const docs = await this.users
|
||||
.find(term ? { $or: [{ name: pattern }, { email: pattern }] } : {})
|
||||
.sort({ name: 1 })
|
||||
.limit(limit)
|
||||
.lean();
|
||||
return docs.map((d) => this.toUser(d));
|
||||
}
|
||||
|
||||
private toUser(doc: UserDocument): User {
|
||||
return this.factory.restore({
|
||||
id: doc._id,
|
||||
name: doc.name,
|
||||
email: doc.email,
|
||||
isActive: doc.isActive,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
|
||||
@Schema({ collection: 'users', timestamps: true, versionKey: false })
|
||||
export class UserDocument {
|
||||
@Prop({ type: String, required: true })
|
||||
_id!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
name!: string;
|
||||
|
||||
@Prop({ required: true, index: true })
|
||||
email!: string;
|
||||
|
||||
@Prop({ default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
export const UserSchema = SchemaFactory.createForClass(UserDocument);
|
||||
UserSchema.index({ name: 'text', email: 'text' });
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
IdentityProvider,
|
||||
InvalidAccessTokenError,
|
||||
InvalidCredentialsError,
|
||||
} from '@task-stream/api/identity/application';
|
||||
import {
|
||||
AuthorizationConfig,
|
||||
TokenGrant,
|
||||
TokenSet,
|
||||
User,
|
||||
UserFactory,
|
||||
} from '@task-stream/api/identity/domain';
|
||||
import { PIREON_CONFIG, PireonConfig } from './pireon.config';
|
||||
|
||||
/** User resource as documented in https://oauth.pireon.dev/docs?api-docs.json */
|
||||
interface PireonUser {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
roles?: string[];
|
||||
app_roles?: string[];
|
||||
}
|
||||
|
||||
/** Driven adapter for the Pireon OAuth2 identity server (Laravel Passport). */
|
||||
@Injectable()
|
||||
export class PireonIdentityProvider extends IdentityProvider {
|
||||
private readonly logger = new Logger(PireonIdentityProvider.name);
|
||||
|
||||
constructor(
|
||||
@Inject(PIREON_CONFIG) private readonly config: PireonConfig,
|
||||
private readonly users: UserFactory,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async whoAmI(accessToken: string): Promise<User> {
|
||||
const response = await this.request('/api/user', accessToken);
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new InvalidAccessTokenError();
|
||||
}
|
||||
this.assertOk(response, 'GET /api/user');
|
||||
return this.toUser((await response.json()) as PireonUser);
|
||||
}
|
||||
|
||||
async findUser(id: string, accessToken: string): Promise<User | null> {
|
||||
if (!/^\d+$/.test(id)) return null;
|
||||
const response = await this.request(`/api/users/${id}`, accessToken);
|
||||
if ([401, 403, 404].includes(response.status)) return null;
|
||||
this.assertOk(response, `GET /api/users/${id}`);
|
||||
return this.toUser((await response.json()) as PireonUser);
|
||||
}
|
||||
|
||||
async listUsers(accessToken: string): Promise<User[]> {
|
||||
const response = await this.request('/api/users', accessToken);
|
||||
if (response.status === 401 || response.status === 403) return [];
|
||||
this.assertOk(response, 'GET /api/users');
|
||||
const body = (await response.json()) as
|
||||
| PireonUser[]
|
||||
| { data: PireonUser[] };
|
||||
const users = Array.isArray(body) ? body : body.data;
|
||||
return users.map((u) => this.toUser(u));
|
||||
}
|
||||
|
||||
async issueToken(grant: TokenGrant): Promise<TokenSet> {
|
||||
const { clientId, clientSecret, scopes } = this.config;
|
||||
if (!clientId)
|
||||
throw new ServiceUnavailableException(
|
||||
'Sign-in is not configured (PIREON_CLIENT_ID)',
|
||||
);
|
||||
|
||||
const body = new URLSearchParams({ client_id: clientId });
|
||||
if (clientSecret) body.set('client_secret', clientSecret);
|
||||
switch (grant.type) {
|
||||
case 'authorization_code':
|
||||
body.set('grant_type', 'authorization_code');
|
||||
body.set('code', grant.code);
|
||||
body.set('code_verifier', grant.codeVerifier);
|
||||
body.set('redirect_uri', grant.redirectUri);
|
||||
break;
|
||||
case 'password':
|
||||
body.set('grant_type', 'password');
|
||||
body.set('username', grant.username);
|
||||
body.set('password', grant.password);
|
||||
body.set('scope', scopes.join(' '));
|
||||
break;
|
||||
case 'refresh_token':
|
||||
body.set('grant_type', 'refresh_token');
|
||||
body.set('refresh_token', grant.refreshToken);
|
||||
break;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(new URL('/oauth/token', this.config.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Pireon unreachable (/oauth/token): ${(error as Error).message}`,
|
||||
);
|
||||
throw new ServiceUnavailableException('Identity server unavailable');
|
||||
}
|
||||
if (response.status === 400 || response.status === 401) {
|
||||
const error = (await response.json().catch(() => ({}))) as {
|
||||
error_description?: string;
|
||||
message?: string;
|
||||
};
|
||||
throw new InvalidCredentialsError(
|
||||
error.error_description ?? error.message ?? 'Invalid credentials',
|
||||
);
|
||||
}
|
||||
this.assertOk(response, 'POST /oauth/token');
|
||||
const token = (await response.json()) as {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
};
|
||||
return {
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token ?? null,
|
||||
expiresIn: token.expires_in,
|
||||
tokenType: token.token_type,
|
||||
};
|
||||
}
|
||||
|
||||
authorizationConfig(): AuthorizationConfig | null {
|
||||
if (!this.config.clientId) return null;
|
||||
return {
|
||||
authorizeUrl: new URL('/oauth/authorize', this.config.baseUrl).toString(),
|
||||
clientId: this.config.clientId,
|
||||
scopes: this.config.scopes,
|
||||
passwordGrant: this.config.passwordGrant,
|
||||
};
|
||||
}
|
||||
|
||||
private toUser(user: PireonUser): User {
|
||||
return this.users.create({
|
||||
id: String(user.id),
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
isActive: user.is_active,
|
||||
roles: user.roles,
|
||||
appRoles: user.app_roles,
|
||||
});
|
||||
}
|
||||
|
||||
private async request(path: string, accessToken: string): Promise<Response> {
|
||||
try {
|
||||
return await fetch(new URL(path, this.config.baseUrl), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Pireon unreachable (${path}): ${(error as Error).message}`,
|
||||
);
|
||||
throw new ServiceUnavailableException('Identity server unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
private assertOk(response: Response, operation: string): void {
|
||||
if (response.ok) return;
|
||||
this.logger.error(`${operation} answered ${response.status}`);
|
||||
throw new ServiceUnavailableException('Identity server error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface PireonConfig {
|
||||
/** e.g. https://oauth.pireon.dev */
|
||||
baseUrl: string;
|
||||
/** Seconds a resolved token is trusted before asking Pireon again. */
|
||||
sessionTtlSeconds: number;
|
||||
requestTimeoutMs: number;
|
||||
/** OAuth client of task-stream in Pireon (Authorization Code + PKCE / password grant). */
|
||||
clientId?: string;
|
||||
/** Kept server-side: browsers sign in through /api/auth/token. */
|
||||
clientSecret?: string;
|
||||
scopes: string[];
|
||||
passwordGrant: boolean;
|
||||
}
|
||||
|
||||
export const PIREON_CONFIG = Symbol('PIREON_CONFIG');
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { SessionCache } from '@task-stream/api/identity/application';
|
||||
import {
|
||||
User,
|
||||
UserFactory,
|
||||
UserSnapshot,
|
||||
} from '@task-stream/api/identity/domain';
|
||||
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
|
||||
import { createHash } from 'node:crypto';
|
||||
import Redis from 'ioredis';
|
||||
import { PIREON_CONFIG, PireonConfig } from '../pireon/pireon.config';
|
||||
|
||||
const sessionKey = (hash: string) => `auth:session:${hash}`;
|
||||
const userSessionsKey = (userId: string) => `auth:user:${userId}:sessions`;
|
||||
|
||||
/** Caches resolved tokens by SHA-256 hash (raw tokens are never stored). */
|
||||
@Injectable()
|
||||
export class RedisSessionCache extends SessionCache {
|
||||
constructor(
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
@Inject(PIREON_CONFIG) private readonly config: PireonConfig,
|
||||
private readonly users: UserFactory,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async get(accessToken: string): Promise<User | null> {
|
||||
const raw = await this.redis.get(sessionKey(hash(accessToken)));
|
||||
return raw ? this.users.restore(JSON.parse(raw) as UserSnapshot) : null;
|
||||
}
|
||||
|
||||
async set(accessToken: string, user: User): Promise<void> {
|
||||
const tokenHash = hash(accessToken);
|
||||
const ttl = this.config.sessionTtlSeconds;
|
||||
await this.redis
|
||||
.multi()
|
||||
.set(sessionKey(tokenHash), JSON.stringify(user.toSnapshot()), 'EX', ttl)
|
||||
.sadd(userSessionsKey(user.id), tokenHash)
|
||||
.expire(userSessionsKey(user.id), ttl)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async evictUser(userId: string): Promise<void> {
|
||||
const hashes = await this.redis.smembers(userSessionsKey(userId));
|
||||
await this.redis.del(userSessionsKey(userId), ...hashes.map(sessionKey));
|
||||
}
|
||||
}
|
||||
|
||||
function hash(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
23
libs/api/identity/infrastructure/tsconfig.json
Normal file
23
libs/api/identity/infrastructure/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
libs/api/identity/infrastructure/tsconfig.lib.json
Normal file
23
libs/api/identity/infrastructure/tsconfig.lib.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"],
|
||||
"target": "es2021",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
16
libs/api/identity/infrastructure/tsconfig.spec.json
Normal file
16
libs/api/identity/infrastructure/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
21
libs/api/identity/presenters/README.md
Normal file
21
libs/api/identity/presenters/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# api-identity-presenters
|
||||
|
||||
Adaptadores de entrada de identidad.
|
||||
|
||||
- **Import**: `@task-stream/api/identity/presenters`
|
||||
- **Capa**: `presenters` · **Tags**: `scope:api-identity`, `type:presenters`
|
||||
|
||||
## Contenido
|
||||
|
||||
- `auth/`: `AuthGuard` global, `@Public()`, `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()` y `WsAuthenticator` (subscriptions).
|
||||
- `http/`: `AuthController` (`GET /api/auth/config`, `POST /api/auth/token`) y `PireonWebhookController` (firma HMAC).
|
||||
- `graphql/`: queries `me`, `user` y `users`.
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-identity-presenters
|
||||
npx nx test api-identity-presenters # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/identity/presenters/eslint.config.mjs
Normal file
3
libs/api/identity/presenters/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/identity/presenters/jest.config.cts
Normal file
11
libs/api/identity/presenters/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-identity-presenters',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/api/identity/presenters',
|
||||
};
|
||||
12
libs/api/identity/presenters/project.json
Normal file
12
libs/api/identity/presenters/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-identity-presenters",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/identity/presenters/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-identity",
|
||||
"type:presenters"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-identity-presenters --web",
|
||||
"targets": {}
|
||||
}
|
||||
5
libs/api/identity/presenters/src/index.ts
Normal file
5
libs/api/identity/presenters/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './lib/identity-presenters.module';
|
||||
export * from './lib/auth/auth.decorators';
|
||||
export * from './lib/auth/auth-request';
|
||||
export * from './lib/auth/ws-authenticator';
|
||||
export * from './lib/graphql/user.type';
|
||||
25
libs/api/identity/presenters/src/lib/auth/auth-request.ts
Normal file
25
libs/api/identity/presenters/src/lib/auth/auth-request.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
|
||||
/** Request shape shared by HTTP, GraphQL and GraphQL-over-WebSocket. */
|
||||
export interface AuthRequest {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
user?: User;
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
export function getAuthRequest(context: ExecutionContext): AuthRequest {
|
||||
if (context.getType<GqlContextType>() === 'graphql') {
|
||||
return GqlExecutionContext.create(context).getContext<{
|
||||
req: AuthRequest;
|
||||
}>().req;
|
||||
}
|
||||
return context.switchToHttp().getRequest<AuthRequest>();
|
||||
}
|
||||
|
||||
export function extractBearerToken(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const [scheme, token] = value.trim().split(/\s+/);
|
||||
return scheme?.toLowerCase() === 'bearer' && token ? token : null;
|
||||
}
|
||||
27
libs/api/identity/presenters/src/lib/auth/auth.decorators.ts
Normal file
27
libs/api/identity/presenters/src/lib/auth/auth.decorators.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
ExecutionContext,
|
||||
SetMetadata,
|
||||
createParamDecorator,
|
||||
} from '@nestjs/common';
|
||||
import { getAuthRequest } from './auth-request';
|
||||
|
||||
export const IS_PUBLIC = 'identity:isPublic';
|
||||
export const REQUIRED_APP_ROLES = 'identity:appRoles';
|
||||
|
||||
/** Skips authentication (health checks, webhooks...). */
|
||||
export const Public = () => SetMetadata(IS_PUBLIC, true);
|
||||
|
||||
/** Requires one of these roles in the task-stream OAuth client (Pireon app roles). */
|
||||
export const RequireAppRoles = (...roles: string[]) =>
|
||||
SetMetadata(REQUIRED_APP_ROLES, roles);
|
||||
|
||||
/** The authenticated caller (User). */
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) => getAuthRequest(context).user,
|
||||
);
|
||||
|
||||
/** The caller's raw access token, to call Pireon on their behalf. */
|
||||
export const AccessToken = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) =>
|
||||
getAuthRequest(context).accessToken,
|
||||
);
|
||||
56
libs/api/identity/presenters/src/lib/auth/auth.guard.ts
Normal file
56
libs/api/identity/presenters/src/lib/auth/auth.guard.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import {
|
||||
IdentityService,
|
||||
InvalidAccessTokenError,
|
||||
} from '@task-stream/api/identity/application';
|
||||
import { extractBearerToken, getAuthRequest } from './auth-request';
|
||||
import { IS_PUBLIC, REQUIRED_APP_ROLES } from './auth.decorators';
|
||||
|
||||
/** Global guard: every HTTP/GraphQL operation needs a valid Pireon token unless @Public(). */
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly identity: IdentityService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const targets = [context.getHandler(), context.getClass()];
|
||||
if (this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, targets))
|
||||
return true;
|
||||
|
||||
const req = getAuthRequest(context);
|
||||
// WebSocket subscriptions are authenticated once in connection_init.
|
||||
if (!req.user) {
|
||||
const token = extractBearerToken(req.headers['authorization']);
|
||||
if (!token) throw new UnauthorizedException('Missing bearer token');
|
||||
try {
|
||||
req.user = await this.identity.resolveSession(token);
|
||||
req.accessToken = token;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidAccessTokenError)
|
||||
throw new UnauthorizedException(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const roles = this.reflector.getAllAndOverride<string[]>(
|
||||
REQUIRED_APP_ROLES,
|
||||
targets,
|
||||
);
|
||||
const user = req.user;
|
||||
if (roles?.length && user && !roles.some((role) => user.hasAppRole(role))) {
|
||||
throw new ForbiddenException(
|
||||
`Requires one of the app roles: ${roles.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { IdentityService } from '@task-stream/api/identity/application';
|
||||
import { AuthRequest, extractBearerToken } from './auth-request';
|
||||
|
||||
/**
|
||||
* Authenticates graphql-ws connections from `connection_init` params:
|
||||
* `{ "authorization": "Bearer <token>" }`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WsAuthenticator {
|
||||
constructor(private readonly identity: IdentityService) {}
|
||||
|
||||
async authenticate(
|
||||
connectionParams: Record<string, unknown> | undefined,
|
||||
): Promise<AuthRequest> {
|
||||
const header =
|
||||
connectionParams?.['authorization'] ??
|
||||
connectionParams?.['Authorization'];
|
||||
const token = extractBearerToken(header);
|
||||
if (!token) throw new Error('Missing bearer token in connection params');
|
||||
const user = await this.identity.resolveSession(token);
|
||||
return {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
user,
|
||||
accessToken: token,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Args, Int, Query, Resolver } from '@nestjs/graphql';
|
||||
import { IdentityService } from '@task-stream/api/identity/application';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import { AccessToken, CurrentUser } from '../auth/auth.decorators';
|
||||
import { MeType, UserType } from './user.type';
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class UserResolver {
|
||||
constructor(private readonly identity: IdentityService) {}
|
||||
|
||||
@Query(() => MeType, { description: 'The authenticated user' })
|
||||
me(@CurrentUser() user: User): MeType {
|
||||
return user.toSnapshot();
|
||||
}
|
||||
|
||||
@Query(() => UserType, { nullable: true })
|
||||
user(
|
||||
@Args('id', { type: () => String }) id: string,
|
||||
@AccessToken() token: string,
|
||||
): Promise<UserType | null> {
|
||||
return this.identity.findUser(id, token);
|
||||
}
|
||||
|
||||
@Query(() => [UserType], {
|
||||
description: 'Search users by name or email (to assign / add members)',
|
||||
})
|
||||
users(
|
||||
@Args('search', { defaultValue: '' }) search: string,
|
||||
@Args('limit', { type: () => Int, defaultValue: 20 }) limit: number,
|
||||
@AccessToken() token: string,
|
||||
): Promise<UserType[]> {
|
||||
return this.identity.searchUsers(search, token, limit);
|
||||
}
|
||||
}
|
||||
29
libs/api/identity/presenters/src/lib/graphql/user.type.ts
Normal file
29
libs/api/identity/presenters/src/lib/graphql/user.type.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('User', {
|
||||
description: 'User managed by the Pireon identity server',
|
||||
})
|
||||
export class UserType {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field()
|
||||
email!: string;
|
||||
|
||||
@Field()
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('Me')
|
||||
export class MeType extends UserType {
|
||||
@Field(() => [String], { description: 'Global roles in Pireon' })
|
||||
roles!: string[];
|
||||
|
||||
@Field(() => [String], {
|
||||
description: 'Roles inside the task-stream OAuth client',
|
||||
})
|
||||
appRoles!: string[];
|
||||
}
|
||||
81
libs/api/identity/presenters/src/lib/http/auth.controller.ts
Normal file
81
libs/api/identity/presenters/src/lib/http/auth.controller.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Post,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
IdentityService,
|
||||
InvalidCredentialsError,
|
||||
} from '@task-stream/api/identity/application';
|
||||
import {
|
||||
AuthorizationConfig,
|
||||
TokenGrant,
|
||||
TokenSet,
|
||||
} from '@task-stream/api/identity/domain';
|
||||
import { Public } from '../auth/auth.decorators';
|
||||
import { IssueTokenDto } from './dto/issue-token.dto';
|
||||
|
||||
/**
|
||||
* Sign-in for browsers. Pireon's /oauth/token has no CORS and the client
|
||||
* secret must stay server-side, so the web app goes through here.
|
||||
*/
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly identity: IdentityService) {}
|
||||
|
||||
/** What the web app needs to start Authorization Code + PKCE (null = not configured). */
|
||||
@Public()
|
||||
@Get('config')
|
||||
config(): Promise<AuthorizationConfig | null> {
|
||||
return this.identity.authorizationConfig();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('token')
|
||||
@HttpCode(200)
|
||||
async token(@Body() dto: IssueTokenDto) {
|
||||
try {
|
||||
return toResponse(await this.identity.issueToken(toGrant(dto)));
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidCredentialsError)
|
||||
throw new UnauthorizedException(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toGrant(dto: IssueTokenDto): TokenGrant {
|
||||
switch (dto.grant_type) {
|
||||
case 'authorization_code':
|
||||
return {
|
||||
type: 'authorization_code',
|
||||
code: dto.code as string,
|
||||
codeVerifier: dto.code_verifier as string,
|
||||
redirectUri: dto.redirect_uri as string,
|
||||
};
|
||||
case 'password':
|
||||
return {
|
||||
type: 'password',
|
||||
username: dto.username as string,
|
||||
password: dto.password as string,
|
||||
};
|
||||
case 'refresh_token':
|
||||
return {
|
||||
type: 'refresh_token',
|
||||
refreshToken: dto.refresh_token as string,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Standard OAuth2 token response. */
|
||||
function toResponse(token: TokenSet) {
|
||||
return {
|
||||
access_token: token.accessToken,
|
||||
refresh_token: token.refreshToken,
|
||||
expires_in: token.expiresIn,
|
||||
token_type: token.tokenType,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { IsIn, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator';
|
||||
|
||||
export class IssueTokenDto {
|
||||
@IsIn(['authorization_code', 'password', 'refresh_token'])
|
||||
grant_type!: 'authorization_code' | 'password' | 'refresh_token';
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code_verifier?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||
@IsUrl({ require_tld: false })
|
||||
redirect_uri?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'refresh_token')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refresh_token?: string;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Post,
|
||||
RawBodyRequest,
|
||||
Req,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { IdentityService } from '@task-stream/api/identity/application';
|
||||
import { Request } from 'express';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { Public } from '../auth/auth.decorators';
|
||||
|
||||
/** Receives `user.deactivated` / `token.revoked` from Pireon. */
|
||||
@Controller('webhooks/pireon')
|
||||
export class PireonWebhookController {
|
||||
constructor(
|
||||
private readonly identity: IdentityService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post()
|
||||
@HttpCode(204)
|
||||
async receive(
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
@Headers('x-webhook-signature') signature?: string,
|
||||
): Promise<void> {
|
||||
this.verifySignature(req.rawBody, signature);
|
||||
const body = req.body as {
|
||||
event?: string;
|
||||
type?: string;
|
||||
data?: unknown;
|
||||
payload?: unknown;
|
||||
};
|
||||
const event = body.event ?? body.type;
|
||||
if (!event) throw new BadRequestException('Missing event name');
|
||||
const data = (body.data ?? body.payload ?? body) as Record<string, unknown>;
|
||||
await this.identity.handleWebhook(event, data);
|
||||
}
|
||||
|
||||
/** X-Webhook-Signature = HMAC-SHA256(raw body, PIREON_WEBHOOK_SECRET). */
|
||||
private verifySignature(
|
||||
rawBody: Buffer | undefined,
|
||||
signature: string | undefined,
|
||||
): void {
|
||||
const secret = this.config.get<string>('PIREON_WEBHOOK_SECRET');
|
||||
if (!secret) {
|
||||
throw new UnauthorizedException(
|
||||
'Webhooks are disabled (PIREON_WEBHOOK_SECRET not set)',
|
||||
);
|
||||
}
|
||||
if (!rawBody || !signature)
|
||||
throw new UnauthorizedException('Missing signature');
|
||||
|
||||
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
const received = signature.replace(/^sha256=/, '');
|
||||
const valid =
|
||||
received.length === expected.length &&
|
||||
timingSafeEqual(Buffer.from(received), Buffer.from(expected));
|
||||
if (!valid) throw new UnauthorizedException('Invalid signature');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { DynamicModule, Module, Type } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AuthGuard } from './auth/auth.guard';
|
||||
import { WsAuthenticator } from './auth/ws-authenticator';
|
||||
import { UserResolver } from './graphql/user.resolver';
|
||||
import { AuthController } from './http/auth.controller';
|
||||
import { PireonWebhookController } from './http/pireon-webhook.controller';
|
||||
|
||||
/** Driving adapters of the identity context: global auth guard, webhook, GraphQL. */
|
||||
@Module({
|
||||
controllers: [AuthController, PireonWebhookController],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||
WsAuthenticator,
|
||||
UserResolver,
|
||||
],
|
||||
exports: [WsAuthenticator],
|
||||
})
|
||||
export class IdentityPresentersModule {
|
||||
static withApplication(
|
||||
applicationModule: Type | DynamicModule,
|
||||
): DynamicModule {
|
||||
return {
|
||||
module: IdentityPresentersModule,
|
||||
imports: [applicationModule],
|
||||
exports: [WsAuthenticator],
|
||||
};
|
||||
}
|
||||
}
|
||||
23
libs/api/identity/presenters/tsconfig.json
Normal file
23
libs/api/identity/presenters/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
libs/api/identity/presenters/tsconfig.lib.json
Normal file
23
libs/api/identity/presenters/tsconfig.lib.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"],
|
||||
"target": "es2021",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
16
libs/api/identity/presenters/tsconfig.spec.json
Normal file
16
libs/api/identity/presenters/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
21
libs/api/shared/application/README.md
Normal file
21
libs/api/shared/application/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# api-shared-application
|
||||
|
||||
Contrato del event store y reconstrucción de agregados.
|
||||
|
||||
- **Import**: `@task-stream/api/shared/application`
|
||||
- **Capa**: `application` · **Tags**: `scope:api-shared`, `type:application`
|
||||
|
||||
## Contenido
|
||||
|
||||
- `EventStore` (puerto): `persist` y `getEventsByStreamId`.
|
||||
- `AggregateRehydrator`: carga los eventos de un stream y devuelve el agregado reconstruido, ya enlazado con el `EventPublisher`.
|
||||
- `SharedModule.withInfrastructure(...)`: módulo global que expone el event store y el rehydrator a todos los contextos.
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-shared-application
|
||||
npx nx test api-shared-application # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/shared/application/eslint.config.mjs
Normal file
3
libs/api/shared/application/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/shared/application/jest.config.cts
Normal file
11
libs/api/shared/application/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-shared-application',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/api/shared/application',
|
||||
};
|
||||
12
libs/api/shared/application/project.json
Normal file
12
libs/api/shared/application/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-shared-application",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/shared/application/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-shared",
|
||||
"type:application"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-shared-application --web",
|
||||
"targets": {}
|
||||
}
|
||||
3
libs/api/shared/application/src/index.ts
Normal file
3
libs/api/shared/application/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './lib/ports/event-store';
|
||||
export * from './lib/aggregate-rehydrator';
|
||||
export * from './lib/shared.module';
|
||||
32
libs/api/shared/application/src/lib/aggregate-rehydrator.ts
Normal file
32
libs/api/shared/application/src/lib/aggregate-rehydrator.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable, Type } from '@nestjs/common';
|
||||
import { EventPublisher } from '@nestjs/cqrs';
|
||||
import {
|
||||
NotFoundError,
|
||||
VersionedAggregateRoot,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { EventStore } from './ports/event-store';
|
||||
|
||||
@Injectable()
|
||||
export class AggregateRehydrator {
|
||||
constructor(
|
||||
private readonly eventStore: EventStore,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async rehydrate<T extends VersionedAggregateRoot>(
|
||||
aggregateId: string,
|
||||
AggregateCls: Type<T>,
|
||||
): Promise<T> {
|
||||
const events = await this.eventStore.getEventsByStreamId(aggregateId);
|
||||
if (events.length === 0) {
|
||||
throw new NotFoundError(AggregateCls.name, aggregateId);
|
||||
}
|
||||
|
||||
const AggregateClsWithDispatcher =
|
||||
this.eventPublisher.mergeClassContext(AggregateCls);
|
||||
const aggregate = new AggregateClsWithDispatcher(aggregateId);
|
||||
|
||||
aggregate.loadFromHistory(events);
|
||||
return aggregate;
|
||||
}
|
||||
}
|
||||
10
libs/api/shared/application/src/lib/ports/event-store.ts
Normal file
10
libs/api/shared/application/src/lib/ports/event-store.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { SerializableEvent } from '@task-stream/api/shared/domain';
|
||||
|
||||
/** Append-only store of domain events (the write model). */
|
||||
export abstract class EventStore {
|
||||
/** Fails with ConcurrencyError if another process appended to the stream first. */
|
||||
abstract persist(
|
||||
eventOrEvents: SerializableEvent | SerializableEvent[],
|
||||
): Promise<void>;
|
||||
abstract getEventsByStreamId(streamId: string): Promise<SerializableEvent[]>;
|
||||
}
|
||||
23
libs/api/shared/application/src/lib/shared.module.ts
Normal file
23
libs/api/shared/application/src/lib/shared.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { DynamicModule, Global, Module, Type } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { AggregateRehydrator } from './aggregate-rehydrator';
|
||||
|
||||
/**
|
||||
* Shared building blocks for every bounded context: the event store (from
|
||||
* the infrastructure module plugged in by the composition root) and the
|
||||
* AggregateRehydrator.
|
||||
*/
|
||||
@Global()
|
||||
@Module({})
|
||||
export class SharedModule {
|
||||
static withInfrastructure(
|
||||
infrastructureModule: Type | DynamicModule,
|
||||
): DynamicModule {
|
||||
return {
|
||||
module: SharedModule,
|
||||
imports: [CqrsModule, infrastructureModule],
|
||||
providers: [AggregateRehydrator],
|
||||
exports: [infrastructureModule, AggregateRehydrator],
|
||||
};
|
||||
}
|
||||
}
|
||||
23
libs/api/shared/application/tsconfig.json
Normal file
23
libs/api/shared/application/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
libs/api/shared/application/tsconfig.lib.json
Normal file
23
libs/api/shared/application/tsconfig.lib.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"],
|
||||
"target": "es2021",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
16
libs/api/shared/application/tsconfig.spec.json
Normal file
16
libs/api/shared/application/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
24
libs/api/shared/domain/README.md
Normal file
24
libs/api/shared/domain/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# api-shared-domain
|
||||
|
||||
Piezas de dominio comunes a todos los contextos de la API.
|
||||
|
||||
- **Import**: `@task-stream/api/shared/domain`
|
||||
- **Capa**: `domain` · **Tags**: `scope:api-shared`, `type:domain`
|
||||
|
||||
## Contenido
|
||||
|
||||
- `VersionedAggregateRoot`: extiende el `AggregateRoot` de `@nestjs/cqrs` con la versión (`loadFromHistory`, `commitAndWait`, `trackCommit`).
|
||||
- `Version`, `SerializableEvent`, `DomainEvent` (actor, fecha y `aggregateVersion`), `newId()`.
|
||||
- `EventClsRegistry` y `@AutowiredEvent`: registro de las clases de evento para deserializarlas.
|
||||
- Errores de dominio: `NotFoundError`, `ForbiddenError`, `ValidationError`, `BusinessRuleError`, `ConcurrencyError` y `assertThat`.
|
||||
|
||||
Event sourcing: [Arquitectura](../../../../docs/02-arquitectura.md#escritura-event-sourcing-en-postgresql).
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-shared-domain
|
||||
npx nx test api-shared-domain # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/shared/domain/eslint.config.mjs
Normal file
3
libs/api/shared/domain/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/shared/domain/jest.config.cts
Normal file
11
libs/api/shared/domain/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-shared-domain',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/api/shared/domain',
|
||||
};
|
||||
12
libs/api/shared/domain/project.json
Normal file
12
libs/api/shared/domain/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-shared-domain",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/shared/domain/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-shared",
|
||||
"type:domain"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-shared-domain --web",
|
||||
"targets": {}
|
||||
}
|
||||
6
libs/api/shared/domain/src/index.ts
Normal file
6
libs/api/shared/domain/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './lib/aggregate-root';
|
||||
export * from './lib/errors';
|
||||
export * from './lib/events/domain-event';
|
||||
export * from './lib/events/event-cls.registry';
|
||||
export * from './lib/interfaces/serializable-event';
|
||||
export * from './lib/value-objects/version';
|
||||
51
libs/api/shared/domain/src/lib/aggregate-root.ts
Normal file
51
libs/api/shared/domain/src/lib/aggregate-root.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { AggregateRoot } from '@nestjs/cqrs';
|
||||
import { SerializableEvent } from './interfaces/serializable-event';
|
||||
import { Version } from './value-objects/version';
|
||||
|
||||
const VERSION = Symbol('version');
|
||||
const PENDING_COMMIT = Symbol('pendingCommit');
|
||||
|
||||
export class VersionedAggregateRoot extends AggregateRoot {
|
||||
public id!: string;
|
||||
|
||||
private [VERSION] = new Version(0);
|
||||
private [PENDING_COMMIT]: Promise<unknown> = Promise.resolve();
|
||||
|
||||
get version(): Version {
|
||||
return this[VERSION];
|
||||
}
|
||||
|
||||
override loadFromHistory(history: SerializableEvent[]): void {
|
||||
const domainEvents = history.map((event) => event.data);
|
||||
super.loadFromHistory(domainEvents);
|
||||
|
||||
const lastEvent = history[history.length - 1];
|
||||
this.setVersion(new Version(lastEvent.position));
|
||||
}
|
||||
|
||||
/**
|
||||
* `commit()` from @nestjs/cqrs is fire-and-forget. This commits and waits
|
||||
* until the event store has persisted the events, so failures (e.g. a
|
||||
* concurrent modification) reach the command handler.
|
||||
*/
|
||||
async commitAndWait(): Promise<void> {
|
||||
this.commit();
|
||||
await this[PENDING_COMMIT];
|
||||
}
|
||||
|
||||
/** Called by the event-store publisher with the persistence of the committed events. */
|
||||
trackCommit(persisted: Promise<unknown>, eventCount: number): void {
|
||||
const from = this.version.value;
|
||||
const tracked = persisted.then(() =>
|
||||
this.setVersion(new Version(from + eventCount)),
|
||||
);
|
||||
// Marked as handled so a bare commit() never crashes the process;
|
||||
// commitAndWait() still receives the error.
|
||||
tracked.catch(() => undefined);
|
||||
this[PENDING_COMMIT] = tracked;
|
||||
}
|
||||
|
||||
private setVersion(version: Version): void {
|
||||
this[VERSION] = version;
|
||||
}
|
||||
}
|
||||
51
libs/api/shared/domain/src/lib/errors.ts
Normal file
51
libs/api/shared/domain/src/lib/errors.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type DomainErrorCode =
|
||||
| 'NOT_FOUND'
|
||||
| 'FORBIDDEN'
|
||||
| 'VALIDATION'
|
||||
| 'CONFLICT'
|
||||
| 'BUSINESS_RULE';
|
||||
|
||||
export abstract class DomainError extends Error {
|
||||
abstract readonly code: DomainErrorCode;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = new.target.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends DomainError {
|
||||
readonly code = 'NOT_FOUND' as const;
|
||||
|
||||
constructor(entity: string, id: string) {
|
||||
super(`${entity} "${id}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends DomainError {
|
||||
readonly code = 'FORBIDDEN' as const;
|
||||
}
|
||||
|
||||
export class ValidationError extends DomainError {
|
||||
readonly code = 'VALIDATION' as const;
|
||||
}
|
||||
|
||||
export class BusinessRuleError extends DomainError {
|
||||
readonly code = 'BUSINESS_RULE' as const;
|
||||
}
|
||||
|
||||
/** Optimistic-locking failure: someone else modified the aggregate first. */
|
||||
export class ConcurrencyError extends DomainError {
|
||||
readonly code = 'CONFLICT' as const;
|
||||
|
||||
constructor(entity: string, id: string) {
|
||||
super(`${entity} "${id}" was modified concurrently, retry the operation`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertThat(
|
||||
condition: unknown,
|
||||
message: string,
|
||||
): asserts condition {
|
||||
if (!condition) throw new ValidationError(message);
|
||||
}
|
||||
17
libs/api/shared/domain/src/lib/events/domain-event.ts
Normal file
17
libs/api/shared/domain/src/lib/events/domain-event.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/** Base class of every domain event: who did it and when. */
|
||||
export abstract class DomainEvent {
|
||||
readonly occurredAt: string = new Date().toISOString();
|
||||
/**
|
||||
* Position of the event in its aggregate stream. Set by the event store
|
||||
* when the event is persisted (undefined on freshly applied events).
|
||||
*/
|
||||
readonly aggregateVersion?: number;
|
||||
|
||||
constructor(readonly actorId: string) {}
|
||||
}
|
||||
|
||||
export function newId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
23
libs/api/shared/domain/src/lib/events/event-cls.registry.ts
Normal file
23
libs/api/shared/domain/src/lib/events/event-cls.registry.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Type } from '@nestjs/common';
|
||||
|
||||
/** Maps stored event type names back to their classes (for deserialization). */
|
||||
export class EventClsRegistry {
|
||||
private static readonly eventClsMap = new Map<string, Type>();
|
||||
|
||||
static add(eventCls: Type): void {
|
||||
this.eventClsMap.set(eventCls.name, eventCls);
|
||||
}
|
||||
|
||||
static get(eventClsName: string): Type {
|
||||
const eventCls = this.eventClsMap.get(eventClsName);
|
||||
if (!eventCls) {
|
||||
throw new Error(`Event class "${eventClsName}" not registered`);
|
||||
}
|
||||
return eventCls;
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers a domain event class so it can be rebuilt from the event store. */
|
||||
export const AutowiredEvent: ClassDecorator = (target) => {
|
||||
EventClsRegistry.add(target as unknown as Type);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Event payload as stored: class instances replaced by their JSON form. */
|
||||
export type SerializedEventPayload<T> = T extends object
|
||||
? {
|
||||
[K in keyof T]: T[K] extends { toJSON(): infer U }
|
||||
? U
|
||||
: SerializedEventPayload<T[K]>;
|
||||
}
|
||||
: T;
|
||||
|
||||
/** A domain event as persisted in the event store. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- payload type is open by design
|
||||
export interface SerializableEvent<T = any> {
|
||||
/** Aggregate id. */
|
||||
streamId: string;
|
||||
/** Event class name, e.g. `IssueCreatedEvent`. */
|
||||
type: string;
|
||||
/** 1-based position inside the stream = aggregate version after the event. */
|
||||
position: number;
|
||||
data: SerializedEventPayload<T>;
|
||||
}
|
||||
4
libs/api/shared/domain/src/lib/value-objects/version.ts
Normal file
4
libs/api/shared/domain/src/lib/value-objects/version.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Position of the last event applied to an aggregate (0 = brand new). */
|
||||
export class Version {
|
||||
constructor(readonly value: number) {}
|
||||
}
|
||||
23
libs/api/shared/domain/tsconfig.json
Normal file
23
libs/api/shared/domain/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
libs/api/shared/domain/tsconfig.lib.json
Normal file
15
libs/api/shared/domain/tsconfig.lib.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
16
libs/api/shared/domain/tsconfig.spec.json
Normal file
16
libs/api/shared/domain/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
25
libs/api/shared/infrastructure/README.md
Normal file
25
libs/api/shared/infrastructure/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# api-shared-infrastructure
|
||||
|
||||
Event store en PostgreSQL y el camino de los eventos hasta la lectura.
|
||||
|
||||
- **Import**: `@task-stream/api/shared/infrastructure`
|
||||
- **Capa**: `infrastructure` · **Tags**: `scope:api-shared`, `type:infrastructure`
|
||||
|
||||
## Contenido
|
||||
|
||||
- `PostgresEventStore` (tabla `events`, `UNIQUE(stream_id, position)` → `ConcurrencyError`).
|
||||
- `EventSerializer` / `EventDeserializer` y `EventStorePublisher` (sustituye al publisher del `EventBus`: `commit()` persiste).
|
||||
- `EventStoreRelay`: copia los eventos nuevos al Redis Stream (`FOR UPDATE SKIP LOCKED`).
|
||||
- `EventsBridge`: consume el stream y entrega cada evento a sus `@EventsHandler`, en orden y esperando (ACK al final).
|
||||
- Conexiones Redis (`REDIS_CLIENT`, `GRAPHQL_PUB_SUB`) y `SharedInfrastructureModule.forRootAsync`.
|
||||
|
||||
Flujo completo: [Arquitectura](../../../../docs/02-arquitectura.md#22-flujo-completo-de-una-escritura).
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-shared-infrastructure
|
||||
npx nx test api-shared-infrastructure # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/shared/infrastructure/eslint.config.mjs
Normal file
3
libs/api/shared/infrastructure/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/shared/infrastructure/jest.config.cts
Normal file
11
libs/api/shared/infrastructure/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-shared-infrastructure',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/api/shared/infrastructure',
|
||||
};
|
||||
12
libs/api/shared/infrastructure/project.json
Normal file
12
libs/api/shared/infrastructure/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-shared-infrastructure",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/shared/infrastructure/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-shared",
|
||||
"type:infrastructure"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-shared-infrastructure --web",
|
||||
"targets": {}
|
||||
}
|
||||
8
libs/api/shared/infrastructure/src/index.ts
Normal file
8
libs/api/shared/infrastructure/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './lib/shared-infrastructure.module';
|
||||
export * from './lib/redis/redis.tokens';
|
||||
export * from './lib/redis/redis-connection.factory';
|
||||
export * from './lib/event-stream/event-stream.options';
|
||||
export * from './lib/event-stream/event-stream.consumer';
|
||||
export * from './lib/event-store/entities/event.entity';
|
||||
export * from './lib/event-store/deserializers/event.deserializer';
|
||||
export * from './lib/event-store/serializers/event.serializer';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Injectable, Type } from '@nestjs/common';
|
||||
import {
|
||||
EventClsRegistry,
|
||||
SerializableEvent,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
|
||||
@Injectable()
|
||||
export class EventDeserializer {
|
||||
deserialize<T>(event: SerializableEvent): SerializableEvent<T> {
|
||||
const eventCls = EventClsRegistry.get(event.type);
|
||||
return {
|
||||
streamId: event.streamId,
|
||||
type: event.type,
|
||||
position: event.position,
|
||||
data: this.instantiateSerializedEvent(eventCls, event.data),
|
||||
};
|
||||
}
|
||||
|
||||
/** Rebuilds the event with its class prototype, without running the constructor. */
|
||||
instantiateSerializedEvent<T extends Type>(
|
||||
eventCls: T,
|
||||
data: Record<string, unknown>,
|
||||
) {
|
||||
return Object.assign(Object.create(eventCls.prototype), data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* Event store (write model). Append-only: one row per domain event.
|
||||
* `UNIQUE(stream_id, position)` gives optimistic concurrency per aggregate.
|
||||
*/
|
||||
@Entity('events')
|
||||
@Unique('uq_events_stream_position', ['streamId', 'position'])
|
||||
@Index('idx_events_unpublished', ['id'], { where: '"published_at" IS NULL' })
|
||||
export class EventEntity {
|
||||
/** Global order of the store, used to relay events in sequence. */
|
||||
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'stream_id', length: 64 })
|
||||
streamId!: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
position!: number;
|
||||
|
||||
@Column()
|
||||
type!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
data!: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
/** When the relay pushed it to the Redis stream. */
|
||||
@Column({ name: 'published_at', type: 'timestamptz', nullable: true })
|
||||
publishedAt!: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { SerializableEvent } from '@task-stream/api/shared/domain';
|
||||
import { Subscription, debounceTime, interval, merge } from 'rxjs';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { EventStreamPublisher } from '../event-stream/event-stream.publisher';
|
||||
import { EventEntity } from './entities/event.entity';
|
||||
import { EventStoreSignal } from './event-store.signal';
|
||||
|
||||
const BATCH_SIZE = 200;
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
|
||||
/**
|
||||
* Forwards new events from the Postgres event store to the Redis stream, in
|
||||
* store order. `FOR UPDATE SKIP LOCKED` lets several instances relay without
|
||||
* duplicates; delivery is at-least-once.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EventStoreRelay
|
||||
implements OnApplicationBootstrap, OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(EventStoreRelay.name);
|
||||
private subscription?: Subscription;
|
||||
private draining?: Promise<void>;
|
||||
private again = false;
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly publisher: EventStreamPublisher,
|
||||
private readonly signal: EventStoreSignal,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
this.subscription = merge(
|
||||
this.signal.appended$.pipe(debounceTime(10)),
|
||||
interval(POLL_INTERVAL_MS),
|
||||
).subscribe(() => void this.drain());
|
||||
}
|
||||
|
||||
/** Runs before connections are closed: stop polling and let the current batch finish. */
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.subscription?.unsubscribe();
|
||||
await this.draining;
|
||||
}
|
||||
|
||||
drain(): Promise<void> {
|
||||
if (this.draining) {
|
||||
this.again = true;
|
||||
return this.draining;
|
||||
}
|
||||
this.draining = this.drainLoop().finally(() => (this.draining = undefined));
|
||||
return this.draining;
|
||||
}
|
||||
|
||||
private async drainLoop(): Promise<void> {
|
||||
try {
|
||||
do {
|
||||
this.again = false;
|
||||
while ((await this.relayBatch()) === BATCH_SIZE);
|
||||
} while (this.again);
|
||||
} catch (error) {
|
||||
this.logger.error(`Event relay failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private relayBatch(): Promise<number> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const rows = await manager
|
||||
.getRepository(EventEntity)
|
||||
.createQueryBuilder('e')
|
||||
.where('e.published_at IS NULL')
|
||||
.orderBy('e.id', 'ASC')
|
||||
.limit(BATCH_SIZE)
|
||||
.setLock('pessimistic_write')
|
||||
.setOnLocked('skip_locked')
|
||||
.getMany();
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
await this.publisher.publish(
|
||||
rows.map(
|
||||
(row): SerializableEvent => ({
|
||||
streamId: row.streamId,
|
||||
type: row.type,
|
||||
position: row.position,
|
||||
data: row.data,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await manager
|
||||
.getRepository(EventEntity)
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ publishedAt: () => 'now()' })
|
||||
.whereInIds(rows.map((r) => r.id))
|
||||
.execute();
|
||||
return rows.length;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
/** In-process nudge so the relay forwards events right after they are stored. */
|
||||
@Injectable()
|
||||
export class EventStoreSignal {
|
||||
private readonly subject = new Subject<void>();
|
||||
readonly appended$ = this.subject.asObservable();
|
||||
|
||||
notify(): void {
|
||||
this.subject.next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
OnApplicationShutdown,
|
||||
Type,
|
||||
} from '@nestjs/common';
|
||||
import { DiscoveryService } from '@nestjs/core';
|
||||
import { IEventHandler } from '@nestjs/cqrs';
|
||||
import { SerializableEvent } from '@task-stream/api/shared/domain';
|
||||
import { hostname } from 'node:os';
|
||||
import { EventStreamConsumer } from '../event-stream/event-stream.consumer';
|
||||
import {
|
||||
EVENT_STREAM_OPTIONS,
|
||||
EventStreamOptions,
|
||||
} from '../event-stream/event-stream.options';
|
||||
import { RedisConnectionFactory } from '../redis/redis-connection.factory';
|
||||
import { EventDeserializer } from './deserializers/event.deserializer';
|
||||
|
||||
export const EVENTS_BRIDGE_ENABLED = Symbol('EVENTS_BRIDGE_ENABLED');
|
||||
|
||||
/** Metadata written by @EventsHandler(...events) (@nestjs/cqrs). */
|
||||
const EVENTS_HANDLER_METADATA = '__eventsHandler__';
|
||||
|
||||
/**
|
||||
* Redis stream → @EventsHandler. Plays the role of the event-store change
|
||||
* stream: every stored event is deserialized and handed to the event
|
||||
* handlers registered for its class (read-model projections, realtime...).
|
||||
*
|
||||
* Unlike `eventBus.subject$.next()`, handlers are awaited one event at a
|
||||
* time, so events of an aggregate are projected in order, and the entry is
|
||||
* acknowledged in Redis only once every handler succeeded (otherwise it
|
||||
* stays pending and is retried).
|
||||
*/
|
||||
@Injectable()
|
||||
export class EventsBridge
|
||||
implements OnApplicationBootstrap, OnApplicationShutdown
|
||||
{
|
||||
private readonly logger = new Logger(EventsBridge.name);
|
||||
private consumer?: EventStreamConsumer;
|
||||
private readonly handlers = new Map<Type, IEventHandler[]>();
|
||||
|
||||
constructor(
|
||||
private readonly connections: RedisConnectionFactory,
|
||||
@Inject(EVENT_STREAM_OPTIONS) private readonly stream: EventStreamOptions,
|
||||
@Inject(EVENTS_BRIDGE_ENABLED) private readonly enabled: boolean,
|
||||
private readonly eventDeserializer: EventDeserializer,
|
||||
private readonly discovery: DiscoveryService,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
if (!this.enabled) {
|
||||
this.logger.log('Events bridge disabled on this instance');
|
||||
return;
|
||||
}
|
||||
this.registerEventHandlers();
|
||||
this.consumer = new EventStreamConsumer(
|
||||
this.connections.create('events-bridge'),
|
||||
{
|
||||
streamKey: this.stream.streamKey,
|
||||
group: 'events-bridge',
|
||||
consumer: hostname(),
|
||||
},
|
||||
(event) => this.handleEvent(event),
|
||||
);
|
||||
await this.consumer.start();
|
||||
this.logger.log(
|
||||
`Bridging ${this.stream.streamKey} to ${this.handlers.size} event type(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.consumer?.stop();
|
||||
}
|
||||
|
||||
private registerEventHandlers(): void {
|
||||
for (const wrapper of this.discovery.getProviders()) {
|
||||
if (!wrapper.metatype || !wrapper.instance) continue;
|
||||
const events: Type[] | undefined = Reflect.getMetadata(
|
||||
EVENTS_HANDLER_METADATA,
|
||||
wrapper.metatype,
|
||||
);
|
||||
for (const eventCls of events ?? []) {
|
||||
this.handlers.set(eventCls, [
|
||||
...(this.handlers.get(eventCls) ?? []),
|
||||
wrapper.instance as IEventHandler,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleEvent(stored: SerializableEvent): Promise<void> {
|
||||
const { data: event } = this.eventDeserializer.deserialize(stored);
|
||||
const eventCls = Object.getPrototypeOf(event).constructor as Type;
|
||||
for (const handler of this.handlers.get(eventCls) ?? []) {
|
||||
await handler.handle(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { EventStore } from '@task-stream/api/shared/application';
|
||||
import {
|
||||
ConcurrencyError,
|
||||
SerializableEvent,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { EventEntity } from './entities/event.entity';
|
||||
import { EventDeserializer } from './deserializers/event.deserializer';
|
||||
import { EventStoreSignal } from './event-store.signal';
|
||||
|
||||
const UNIQUE_VIOLATION = '23505';
|
||||
|
||||
@Injectable()
|
||||
export class PostgresEventStore extends EventStore {
|
||||
private readonly logger = new Logger(PostgresEventStore.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly deserializer: EventDeserializer,
|
||||
private readonly signal: EventStoreSignal,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async persist(
|
||||
eventOrEvents: SerializableEvent | SerializableEvent[],
|
||||
): Promise<void> {
|
||||
const events = Array.isArray(eventOrEvents)
|
||||
? eventOrEvents
|
||||
: [eventOrEvents];
|
||||
if (events.length === 0) return;
|
||||
|
||||
try {
|
||||
await this.dataSource.transaction((manager) =>
|
||||
manager.save(
|
||||
EventEntity,
|
||||
events.map((e) =>
|
||||
manager.create(EventEntity, {
|
||||
streamId: e.streamId,
|
||||
position: e.position,
|
||||
type: e.type,
|
||||
data: e.data as Record<string, unknown>,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
this.logger.debug(`Events inserted successfully to the event store`);
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === UNIQUE_VIOLATION) {
|
||||
this.logger.warn(
|
||||
`Events could not be persisted. Aggregate ${events[0].streamId} is stale.`,
|
||||
);
|
||||
throw new ConcurrencyError('Aggregate', events[0].streamId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.signal.notify();
|
||||
}
|
||||
|
||||
async getEventsByStreamId(streamId: string): Promise<SerializableEvent[]> {
|
||||
const rows = await this.dataSource
|
||||
.getRepository(EventEntity)
|
||||
.find({ where: { streamId }, order: { position: 'ASC' } });
|
||||
return rows.map((row) =>
|
||||
this.deserializer.deserialize({
|
||||
streamId: row.streamId,
|
||||
type: row.type,
|
||||
position: row.position,
|
||||
data: row.data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user