first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View File

@@ -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();
}
}

View File

@@ -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);
});
});

View File

@@ -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;
}
}

View File

@@ -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);
}
}