Compare commits
3 Commits
66b09ecf4d
...
3c7c1f7f0e
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c7c1f7f0e | |||
| f986f17968 | |||
| 5ae1d05d30 |
@@ -16,8 +16,11 @@ EVENTS_BRIDGE_ENABLED=true
|
|||||||
|
|
||||||
# Pireon identity server (https://oauth.pireon.dev/api/documentation)
|
# Pireon identity server (https://oauth.pireon.dev/api/documentation)
|
||||||
PIREON_BASE_URL=https://oauth.pireon.dev
|
PIREON_BASE_URL=https://oauth.pireon.dev
|
||||||
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks)
|
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks).
|
||||||
|
# Pireon signs with the secret of the client that registered it: one per client.
|
||||||
PIREON_WEBHOOK_SECRET=
|
PIREON_WEBHOOK_SECRET=
|
||||||
|
# The password client's webhook, if it has its own
|
||||||
|
PIREON_PASSWORD_WEBHOOK_SECRET=
|
||||||
# OAuth clients of task-stream in Pireon (POST /api/clients). Each Pireon client allows ONE grant type:
|
# OAuth clients of task-stream in Pireon (POST /api/clients). Each Pireon client allows ONE grant type:
|
||||||
# authorization_code → "Sign in with Pireon" and Swagger UI. Redirect URIs to register:
|
# authorization_code → "Sign in with Pireon" and Swagger UI. Redirect URIs to register:
|
||||||
# http://localhost:4200/auth/callback, http://localhost:3000/api/docs/oauth2-redirect.html
|
# http://localhost:4200/auth/callback, http://localhost:3000/api/docs/oauth2-redirect.html
|
||||||
|
|||||||
@@ -344,7 +344,7 @@
|
|||||||
},
|
},
|
||||||
"/api/webhooks/pireon": {
|
"/api/webhooks/pireon": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET is set.",
|
"description": "`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET is set.",
|
||||||
"operationId": "PireonWebhookController_receive",
|
"operationId": "PireonWebhookController_receive",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -358,7 +358,7 @@
|
|||||||
{
|
{
|
||||||
"name": "X-Webhook-Signature",
|
"name": "X-Webhook-Signature",
|
||||||
"in": "header",
|
"in": "header",
|
||||||
"description": "HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)",
|
"description": "HMAC-SHA256 of the raw body with the secret of the Pireon client that registered the webhook (hex)",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export const configSchema = Joi.object({
|
|||||||
// Pireon identity server
|
// Pireon identity server
|
||||||
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
|
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
|
||||||
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
|
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
|
||||||
|
// The password client registers its own webhook, signed with its own secret.
|
||||||
|
PIREON_PASSWORD_WEBHOOK_SECRET: Joi.string().allow('').optional(),
|
||||||
// OAuth clients of task-stream in Pireon (each Pireon client allows one grant type):
|
// OAuth clients of task-stream in Pireon (each Pireon client allows one grant type):
|
||||||
// authorization_code for "Sign in with Pireon" / Swagger UI…
|
// authorization_code for "Sign in with Pireon" / Swagger UI…
|
||||||
PIREON_CLIENT_ID: Joi.string().allow('').optional(),
|
PIREON_CLIENT_ID: Joi.string().allow('').optional(),
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export interface paths {
|
|||||||
put?: never;
|
put?: never;
|
||||||
/**
|
/**
|
||||||
* Pireon events (called by Pireon, not by apps)
|
* Pireon events (called by Pireon, not by apps)
|
||||||
* @description `user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET is set.
|
* @description `user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET is set.
|
||||||
*/
|
*/
|
||||||
post: operations["PireonWebhookController_receive"];
|
post: operations["PireonWebhookController_receive"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -2825,7 +2825,7 @@ export interface operations {
|
|||||||
query?: never;
|
query?: never;
|
||||||
header: {
|
header: {
|
||||||
"x-webhook-signature": string;
|
"x-webhook-signature": string;
|
||||||
/** @description HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex) */
|
/** @description HMAC-SHA256 of the raw body with the secret of the Pireon client that registered the webhook (hex) */
|
||||||
"X-Webhook-Signature": string;
|
"X-Webhook-Signature": string;
|
||||||
};
|
};
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ services:
|
|||||||
EVENTS_BRIDGE_ENABLED: 'true'
|
EVENTS_BRIDGE_ENABLED: 'true'
|
||||||
PIREON_BASE_URL: https://oauth.pireon.dev
|
PIREON_BASE_URL: https://oauth.pireon.dev
|
||||||
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
|
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
|
||||||
|
PIREON_PASSWORD_WEBHOOK_SECRET: ${PIREON_PASSWORD_WEBHOOK_SECRET:-}
|
||||||
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
|
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
|
||||||
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
|
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
|
||||||
# Pireon clients allow one grant type: the one above is authorization_code,
|
# Pireon clients allow one grant type: the one above is authorization_code,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
|
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
|
||||||
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
||||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien comenta, los sigue solo |
|
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo |
|
||||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
|
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
|
||||||
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
||||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada
|
|||||||
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
|
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
|
||||||
| `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog |
|
| `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog |
|
||||||
| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket |
|
| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket |
|
||||||
|
| `PeopleFollowSaga` | `IssueCreatedEvent`, `IssueAssignedEvent` | `WatchIssueCommand`: quien abre el ticket y quien lo tiene asignado pasan a seguirlo |
|
||||||
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
|
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
|
||||||
|
|
||||||
Reglas que cumplen:
|
Reglas que cumplen:
|
||||||
|
|||||||
@@ -251,7 +251,8 @@ Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por event
|
|||||||
**Menciones**: se escriben `@[Nombre](id-de-usuario)` (la web las inserta al escribir `@`); el id dice a quién avisar y
|
**Menciones**: se escriben `@[Nombre](id-de-usuario)` (la web las inserta al escribir `@`); el id dice a quién avisar y
|
||||||
el nombre es lo que se ve, así que renombrar a alguien no rompe comentarios antiguos. Solo avisan a miembros del
|
el nombre es lo que se ve, así que renombrar a alguien no rompe comentarios antiguos. Solo avisan a miembros del
|
||||||
proyecto. **Seguir**: cualquier miembro, también los lectores, puede seguir un ticket (`watchIssue`/`unwatchIssue`,
|
proyecto. **Seguir**: cualquier miembro, también los lectores, puede seguir un ticket (`watchIssue`/`unwatchIssue`,
|
||||||
eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien comenta pasa a seguirlo (`CommentersFollowSaga`). Quien
|
eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo tiene asignado y quien comenta pasan a seguirlo (`PeopleFollowSaga`,
|
||||||
|
`CommentersFollowSaga`). Quien
|
||||||
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
|
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
|
||||||
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
|
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
|
||||||
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
|
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
|
||||||
|
|||||||
@@ -122,11 +122,16 @@ caduque la caché):
|
|||||||
1. Registra `https://<tu-api>/api/webhooks/pireon` con los eventos `user.deactivated` y `token.revoked`
|
1. Registra `https://<tu-api>/api/webhooks/pireon` con los eventos `user.deactivated` y `token.revoked`
|
||||||
(`POST /api/clients/{client}/webhooks`).
|
(`POST /api/clients/{client}/webhooks`).
|
||||||
2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`.
|
2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`.
|
||||||
|
3. Si usas un cliente aparte para el acceso con contraseña (`PIREON_PASSWORD_CLIENT_ID`), registra el webhook también
|
||||||
|
en ese cliente y pon su secret en `PIREON_PASSWORD_WEBHOOK_SECRET`. Pireon firma cada aviso con el secret del
|
||||||
|
cliente que lo registró, y la revocación de un token solo la avisa el cliente que lo emitió: sin este paso, los
|
||||||
|
tokens de quien entra con contraseña no se olvidan al revocarlos.
|
||||||
|
|
||||||
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo), marca al usuario como inactivo y borra sus
|
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo, válida con cualquiera de los dos secrets),
|
||||||
|
marca al usuario como inactivo y borra sus
|
||||||
sesiones cacheadas. Además guarda `UserDeactivatedEvent` en el event store, y una saga del tracker deja sin asignar sus
|
sesiones cacheadas. Además guarda `UserDeactivatedEvent` en el event store, y una saga del tracker deja sin asignar sus
|
||||||
tickets abiertos en todos los proyectos (ver [Arquitectura](02-arquitectura.md)). Sin `PIREON_WEBHOOK_SECRET`, el
|
tickets abiertos en todos los proyectos (ver [Arquitectura](02-arquitectura.md)). Repetir un aviso (llega uno por
|
||||||
endpoint rechaza todas las llamadas.
|
cliente) no tiene efecto. Sin ningún secret, el endpoint rechaza todas las llamadas.
|
||||||
|
|
||||||
## 5.4 Usuarios en TaskStream
|
## 5.4 Usuarios en TaskStream
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ donde los lee Compose.
|
|||||||
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
|
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
|
||||||
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
|
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
|
||||||
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
|
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
|
||||||
|
| `PIREON_PASSWORD_WEBHOOK_SECRET` | — | El del webhook del cliente de contraseña, si es otro cliente |
|
||||||
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon |
|
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon |
|
||||||
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
|
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
|
||||||
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
|
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,48 @@
|
|||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { IdentityService } from '@task-stream/api/identity/application';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { PireonWebhookController } from './pireon-webhook.controller';
|
||||||
|
|
||||||
|
describe('PireonWebhookController', () => {
|
||||||
|
const handled: string[] = [];
|
||||||
|
const controller = (secrets: Record<string, string>) =>
|
||||||
|
new PireonWebhookController(
|
||||||
|
{
|
||||||
|
handleWebhook: async (event: string) => {
|
||||||
|
handled.push(event);
|
||||||
|
},
|
||||||
|
} as unknown as IdentityService,
|
||||||
|
{ get: (key: string) => secrets[key] } as unknown as ConfigService,
|
||||||
|
);
|
||||||
|
const body = { event: 'token.revoked', data: { token_id: 't-1' } };
|
||||||
|
const raw = Buffer.from(JSON.stringify(body));
|
||||||
|
const signed = (secret: string) =>
|
||||||
|
createHmac('sha256', secret).update(raw).digest('hex');
|
||||||
|
const request = { rawBody: raw, body } as never;
|
||||||
|
|
||||||
|
beforeEach(() => (handled.length = 0));
|
||||||
|
|
||||||
|
it("accepts a delivery signed with either Pireon client's secret", async () => {
|
||||||
|
const both = controller({
|
||||||
|
PIREON_WEBHOOK_SECRET: 'web-secret',
|
||||||
|
PIREON_PASSWORD_WEBHOOK_SECRET: 'password-secret',
|
||||||
|
});
|
||||||
|
await both.receive(request, signed('web-secret'));
|
||||||
|
await both.receive(request, `sha256=${signed('password-secret')}`);
|
||||||
|
expect(handled).toEqual(['token.revoked', 'token.revoked']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses any other signature, and everything when no secret is set', async () => {
|
||||||
|
await expect(
|
||||||
|
controller({ PIREON_WEBHOOK_SECRET: 'web-secret' }).receive(
|
||||||
|
request,
|
||||||
|
signed('password-secret'),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
await expect(
|
||||||
|
controller({}).receive(request, signed('web-secret')),
|
||||||
|
).rejects.toThrow(/disabled/);
|
||||||
|
expect(handled).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,7 +21,13 @@ import { Request } from 'express';
|
|||||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
import { Public } from '../auth/auth.decorators';
|
import { Public } from '../auth/auth.decorators';
|
||||||
|
|
||||||
/** Receives `user.deactivated` / `token.revoked` from Pireon. */
|
/**
|
||||||
|
* Receives `user.deactivated` / `token.revoked` from Pireon.
|
||||||
|
*
|
||||||
|
* Pireon signs each webhook with the secret of the client that registered
|
||||||
|
* it, and this API has two (the web app's and the password grant's): a
|
||||||
|
* delivery is good if it verifies with either.
|
||||||
|
*/
|
||||||
@ApiTags('Webhooks')
|
@ApiTags('Webhooks')
|
||||||
@Controller('webhooks/pireon')
|
@Controller('webhooks/pireon')
|
||||||
export class PireonWebhookController {
|
export class PireonWebhookController {
|
||||||
@@ -37,11 +43,12 @@ export class PireonWebhookController {
|
|||||||
summary: 'Pireon events (called by Pireon, not by apps)',
|
summary: 'Pireon events (called by Pireon, not by apps)',
|
||||||
description:
|
description:
|
||||||
"`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " +
|
"`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " +
|
||||||
'Disabled unless PIREON_WEBHOOK_SECRET is set.',
|
'Disabled unless PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET is set.',
|
||||||
})
|
})
|
||||||
@ApiHeader({
|
@ApiHeader({
|
||||||
name: 'X-Webhook-Signature',
|
name: 'X-Webhook-Signature',
|
||||||
description: 'HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)',
|
description:
|
||||||
|
'HMAC-SHA256 of the raw body with the secret of the Pireon client that registered the webhook (hex)',
|
||||||
required: true,
|
required: true,
|
||||||
})
|
})
|
||||||
@ApiNoContentResponse({ description: 'Processed' })
|
@ApiNoContentResponse({ description: 'Processed' })
|
||||||
@@ -63,13 +70,16 @@ export class PireonWebhookController {
|
|||||||
await this.identity.handleWebhook(event, data);
|
await this.identity.handleWebhook(event, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** X-Webhook-Signature = HMAC-SHA256(raw body, PIREON_WEBHOOK_SECRET). */
|
/** X-Webhook-Signature = HMAC-SHA256(raw body, the client's webhook secret). */
|
||||||
private verifySignature(
|
private verifySignature(
|
||||||
rawBody: Buffer | undefined,
|
rawBody: Buffer | undefined,
|
||||||
signature: string | undefined,
|
signature: string | undefined,
|
||||||
): void {
|
): void {
|
||||||
const secret = this.config.get<string>('PIREON_WEBHOOK_SECRET');
|
const secrets = [
|
||||||
if (!secret) {
|
this.config.get<string>('PIREON_WEBHOOK_SECRET'),
|
||||||
|
this.config.get<string>('PIREON_PASSWORD_WEBHOOK_SECRET'),
|
||||||
|
].filter((secret): secret is string => !!secret);
|
||||||
|
if (!secrets.length) {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
'Webhooks are disabled (PIREON_WEBHOOK_SECRET not set)',
|
'Webhooks are disabled (PIREON_WEBHOOK_SECRET not set)',
|
||||||
);
|
);
|
||||||
@@ -77,11 +87,16 @@ export class PireonWebhookController {
|
|||||||
if (!rawBody || !signature)
|
if (!rawBody || !signature)
|
||||||
throw new UnauthorizedException('Missing signature');
|
throw new UnauthorizedException('Missing signature');
|
||||||
|
|
||||||
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
|
const received = Buffer.from(signature.replace(/^sha256=/, ''));
|
||||||
const received = signature.replace(/^sha256=/, '');
|
const valid = secrets.some((secret) => {
|
||||||
const valid =
|
const expected = Buffer.from(
|
||||||
received.length === expected.length &&
|
createHmac('sha256', secret).update(rawBody).digest('hex'),
|
||||||
timingSafeEqual(Buffer.from(received), Buffer.from(expected));
|
);
|
||||||
|
return (
|
||||||
|
received.length === expected.length &&
|
||||||
|
timingSafeEqual(received, expected)
|
||||||
|
);
|
||||||
|
});
|
||||||
if (!valid) throw new UnauthorizedException('Invalid signature');
|
if (!valid) throw new UnauthorizedException('Invalid signature');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
IssueAssignedEvent,
|
||||||
|
IssueCreatedEvent,
|
||||||
|
IssuePriority,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { lastValueFrom, of, toArray } from 'rxjs';
|
||||||
|
import { WatchIssueCommand } from '../commands/watch-issue.command';
|
||||||
|
import { PeopleFollowSaga } from './people-follow.saga';
|
||||||
|
|
||||||
|
describe('PeopleFollowSaga', () => {
|
||||||
|
const saga = new PeopleFollowSaga();
|
||||||
|
const created = (assigneeId: string | null) =>
|
||||||
|
new IssueCreatedEvent('ana', {
|
||||||
|
id: 'i-1',
|
||||||
|
projectId: 'p-1',
|
||||||
|
reporterId: 'ana',
|
||||||
|
assigneeId,
|
||||||
|
priority: IssuePriority.MEDIUM,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
it('makes whoever opens a ticket, and whoever gets it, follow it', async () => {
|
||||||
|
expect(
|
||||||
|
await lastValueFrom(saga.created(of(created('bob'))).pipe(toArray())),
|
||||||
|
).toEqual([
|
||||||
|
new WatchIssueCommand('ana', 'i-1', 'ana'),
|
||||||
|
new WatchIssueCommand('bob', 'i-1', 'bob'),
|
||||||
|
]);
|
||||||
|
// Opening a ticket for oneself: once.
|
||||||
|
expect(
|
||||||
|
await lastValueFrom(saga.created(of(created('ana'))).pipe(toArray())),
|
||||||
|
).toEqual([new WatchIssueCommand('ana', 'i-1', 'ana')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes whoever a ticket is assigned to follow it, not when unassigned', async () => {
|
||||||
|
const commands = await lastValueFrom(
|
||||||
|
saga
|
||||||
|
.assigned(
|
||||||
|
of(
|
||||||
|
new IssueAssignedEvent('ana', 'i-1', 'p-1', 'bob', null),
|
||||||
|
new IssueAssignedEvent('ana', 'i-1', 'p-1', null, 'bob'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.pipe(toArray()),
|
||||||
|
);
|
||||||
|
expect(commands).toEqual([new WatchIssueCommand('bob', 'i-1', 'bob')]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
IssueAssignedEvent,
|
||||||
|
IssueCreatedEvent,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { filter, mergeMap, Observable } from 'rxjs';
|
||||||
|
import { WatchIssueCommand } from '../commands/watch-issue.command';
|
||||||
|
|
||||||
|
/** Follows a ticket as `userId` would by pressing "Seguir". */
|
||||||
|
const follow = (userId: string, issueId: string) =>
|
||||||
|
new WatchIssueCommand(userId, issueId, userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whoever opens a ticket and whoever it is assigned to follow it from then
|
||||||
|
* on, as a commenter does: its changes concern them. Unfollowing stays
|
||||||
|
* theirs to do. (Idempotent: following twice changes nothing; someone who
|
||||||
|
* is not a member, like the system, is refused and skipped.)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PeopleFollowSaga {
|
||||||
|
@Saga()
|
||||||
|
created = (events$: Observable<IEvent>): Observable<ICommand> =>
|
||||||
|
events$.pipe(
|
||||||
|
ofType(IssueCreatedEvent),
|
||||||
|
mergeMap(({ issue }) =>
|
||||||
|
[...new Set([issue.reporterId, issue.assigneeId])]
|
||||||
|
.filter((userId): userId is string => !!userId)
|
||||||
|
.map((userId) => follow(userId, issue.id)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
@Saga()
|
||||||
|
assigned = (events$: Observable<IEvent>): Observable<ICommand> =>
|
||||||
|
events$.pipe(
|
||||||
|
ofType(IssueAssignedEvent),
|
||||||
|
filter((event) => !!event.assigneeId),
|
||||||
|
mergeMap((event) => [
|
||||||
|
follow(event.assigneeId as string, event.aggregateId),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ProjectDeletionSaga } from './project-deletion.saga';
|
|||||||
import { ReleaseArchivedSaga } from './release-archived.saga';
|
import { ReleaseArchivedSaga } from './release-archived.saga';
|
||||||
import { UserDeactivatedSaga } from './user-deactivated.saga';
|
import { UserDeactivatedSaga } from './user-deactivated.saga';
|
||||||
import { CommentersFollowSaga } from './commenters-follow.saga';
|
import { CommentersFollowSaga } from './commenters-follow.saga';
|
||||||
|
import { PeopleFollowSaga } from './people-follow.saga';
|
||||||
import { SprintCarryOverSaga } from './sprint-carry-over.saga';
|
import { SprintCarryOverSaga } from './sprint-carry-over.saga';
|
||||||
|
|
||||||
/** Reactions to events that change other aggregates (run by the events bridge). */
|
/** Reactions to events that change other aggregates (run by the events bridge). */
|
||||||
@@ -10,5 +11,6 @@ export const SAGAS = [
|
|||||||
ReleaseArchivedSaga,
|
ReleaseArchivedSaga,
|
||||||
UserDeactivatedSaga,
|
UserDeactivatedSaga,
|
||||||
CommentersFollowSaga,
|
CommentersFollowSaga,
|
||||||
|
PeopleFollowSaga,
|
||||||
SprintCarryOverSaga,
|
SprintCarryOverSaga,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -608,9 +608,33 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
novalidate
|
novalidate
|
||||||
>
|
>
|
||||||
<pi-textarea
|
<pi-textarea
|
||||||
[placeholder]="'Responde a ' + thread.comment.author.name + '…'"
|
[placeholder]="
|
||||||
|
'Responde a ' +
|
||||||
|
thread.comment.author.name +
|
||||||
|
'… Escribe @ para mencionar a alguien.'
|
||||||
|
"
|
||||||
[(value)]="replyBody"
|
[(value)]="replyBody"
|
||||||
/>
|
/>
|
||||||
|
@if (replyMentionOptions().length) {
|
||||||
|
<div
|
||||||
|
class="ts-mentions"
|
||||||
|
role="group"
|
||||||
|
aria-label="Mencionar a"
|
||||||
|
>
|
||||||
|
@for (member of replyMentionOptions(); track member.id) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-mentions__option"
|
||||||
|
(click)="mentionInReply(member)"
|
||||||
|
>
|
||||||
|
<span class="ts-comment__avatar" aria-hidden="true">{{
|
||||||
|
initialsOf(member.name)
|
||||||
|
}}</span>
|
||||||
|
{{ member.name }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="ts-reply__actions">
|
<div class="ts-reply__actions">
|
||||||
<pi-action-button
|
<pi-action-button
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -1024,17 +1048,13 @@ export class IssuePage {
|
|||||||
protected readonly commentFiles = signal<File[]>([]);
|
protected readonly commentFiles = signal<File[]>([]);
|
||||||
protected readonly segments = mentionSegments;
|
protected readonly segments = mentionSegments;
|
||||||
/** Members matching what is typed after an `@` at the end of the comment. */
|
/** Members matching what is typed after an `@` at the end of the comment. */
|
||||||
protected readonly mentionOptions = computed(() => {
|
protected readonly mentionOptions = computed(() =>
|
||||||
const query = mentionQuery(this.commentBody());
|
this.membersMatching(this.commentBody()),
|
||||||
if (query === null) return [];
|
);
|
||||||
return this.project
|
/** The same, for the answer being written. */
|
||||||
.members()
|
protected readonly replyMentionOptions = computed(() =>
|
||||||
.map((m) => m.user)
|
this.membersMatching(this.replyBody()),
|
||||||
.filter(
|
);
|
||||||
(user) => user.id !== this.facade.me() && matches(user.name, query),
|
|
||||||
)
|
|
||||||
.slice(0, 6);
|
|
||||||
});
|
|
||||||
/** Pull requests first, then the newest. */
|
/** Pull requests first, then the newest. */
|
||||||
protected readonly devLinks = computed(() =>
|
protected readonly devLinks = computed(() =>
|
||||||
[...(this.facade.issue()?.devLinks ?? [])].sort(
|
[...(this.facade.issue()?.devLinks ?? [])].sort(
|
||||||
@@ -1226,6 +1246,23 @@ export class IssuePage {
|
|||||||
this.replyingTo.set(null);
|
this.replyingTo.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Members whose name fits the `@…` being typed at the end of `text`. */
|
||||||
|
private membersMatching(text: string) {
|
||||||
|
const query = mentionQuery(text);
|
||||||
|
if (query === null) return [];
|
||||||
|
return this.project
|
||||||
|
.members()
|
||||||
|
.map((m) => m.user)
|
||||||
|
.filter(
|
||||||
|
(user) => user.id !== this.facade.me() && matches(user.name, query),
|
||||||
|
)
|
||||||
|
.slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected mentionInReply(user: { id: string; name: string }): void {
|
||||||
|
this.replyBody.update((text) => insertMention(text, user));
|
||||||
|
}
|
||||||
|
|
||||||
protected mention(user: { id: string; name: string }): void {
|
protected mention(user: { id: string; name: string }): void {
|
||||||
this.commentBody.update((text) => insertMention(text, user));
|
this.commentBody.update((text) => insertMention(text, user));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user