mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 15:59:40 +02:00
This commit bundles two unrelated changes that were swept together by an
accidental `git add -A` in another working session. Documented here so the
history reflects what's actually inside.
═══════════════════════════════════════════════════════════════════════
1. fix(mana-auth): /api/v1/auth/login mints JWT via auth.handler instead
of api.signInEmail
═══════════════════════════════════════════════════════════════════════
Previous attempt (commit 55cc75e7d) tried to fix the broken JWT mint in
/api/v1/auth/login by switching the cookie name from `mana.session_token`
to `__Secure-mana.session_token` for production. That was necessary but
not sufficient: Better Auth's session cookie value isn't just the raw
session token, it's `<token>.<HMAC>` where the HMAC is derived from the
better-auth secret. Reconstructing the cookie from auth.api.signInEmail's
JSON response only gave us the raw token, so /api/auth/token's
get-session middleware still couldn't validate it and the JWT mint kept
silently failing.
Real fix: do the sign-in via auth.handler (the HTTP path) rather than
auth.api.signInEmail (the SDK path). The handler returns a real fetch
Response with a Set-Cookie header containing the fully signed cookie
envelope. We capture that header verbatim and forward it as the cookie
on the /api/auth/token request, which now passes validation and mints
the JWT correctly.
Verified end-to-end on auth.mana.how:
$ curl -X POST https://auth.mana.how/api/v1/auth/login \
-d '{"email":"...","password":"..."}'
{
"user": {...},
"token": "<session token>",
"accessToken": "eyJhbGciOiJFZERTQSI...", ← real JWT now
"refreshToken": "<session token>"
}
Side benefits:
- Email-not-verified path is now handled by checking
signInResponse.status === 403 directly, no more catching APIError
with the comment-noted async-stream footgun.
- X-Forwarded-For is forwarded explicitly so Better Auth's rate limiter
and our security log see the real client IP.
- The leftover catch block now only handles unexpected exceptions
(network errors etc); the FORBIDDEN-checking logic in it is dead but
harmless and left in for defense in depth.
═══════════════════════════════════════════════════════════════════════
2. chore: remove the entire self-hosted Matrix stack (Synapse, Element,
Manalink, mana-matrix-bot)
═══════════════════════════════════════════════════════════════════════
The Matrix subsystem ran parallel to the main Mana product without any
load-bearing integration: the unified web app never imported matrix-js-sdk,
the chat module uses mana-sync (local-first), and mana-matrix-bot's
plugins duplicated features the unified app already ships natively.
Keeping it alive cost a Synapse + Element + matrix-web + bot container
quartet, three Cloudflare routes, an OIDC provider plugin in mana-auth,
and a steady drip of devlog/dependency churn.
Removed:
- apps/matrix (Manalink web + mobile, ~150 files)
- services/mana-matrix-bot (Go bot with ~20 plugins)
- docker/matrix configs (Synapse + Element)
- synapse/element-web/matrix-web/mana-matrix-bot services in
docker-compose.macmini.yml
- matrix.mana.how/element.mana.how/link.mana.how Cloudflare tunnel routes
- OIDC provider plugin + matrix-synapse trustedClient + matrixUserLinks
table from mana-auth (oauth_* schema definitions also removed)
- MatrixService import path in mana-media (importFromMatrix endpoint)
- Matrix notification channel in mana-notify (worker, metrics, config,
channel_type enum, MatrixOptions handler)
- Matrix entries from shared-branding (mana-apps + app-icons),
notify-client, the i18n bundle, the observatory map, the credits
app-label list, the landing footer/apps page, the prometheus + alerts
+ promtail tier mappings, and the matrix-related deploy paths in
cd-macmini.yml + ci.yml
Devlog/manascore/blueprint entries that mention Matrix are left intact
as historical record. The oauth_* + matrix_user_links Postgres tables
stay on existing prod databases — code can no longer write to them, drop
them in a follow-up migration if you want them gone for real.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
337 lines
8.4 KiB
TypeScript
337 lines
8.4 KiB
TypeScript
import type {
|
|
SendEmailOptions,
|
|
SendPushOptions,
|
|
SendWebhookOptions,
|
|
ScheduleOptions,
|
|
NotificationResponse,
|
|
BatchNotificationResponse,
|
|
Template,
|
|
RenderedTemplate,
|
|
} from './types';
|
|
|
|
export interface NotifyClientOptions {
|
|
serviceUrl: string;
|
|
serviceKey: string;
|
|
appId: string;
|
|
timeout?: number;
|
|
}
|
|
|
|
export class NotifyClient {
|
|
private readonly serviceUrl: string;
|
|
private readonly serviceKey: string;
|
|
private readonly appId: string;
|
|
private readonly timeout: number;
|
|
|
|
constructor(options: NotifyClientOptions) {
|
|
this.serviceUrl = options.serviceUrl.replace(/\/$/, '');
|
|
this.serviceKey = options.serviceKey;
|
|
this.appId = options.appId;
|
|
this.timeout = options.timeout || 30000;
|
|
}
|
|
|
|
// ==================== Notifications ====================
|
|
|
|
/**
|
|
* Send an email notification
|
|
*/
|
|
async sendEmail(options: SendEmailOptions): Promise<NotificationResponse> {
|
|
const payload: Record<string, unknown> = {
|
|
channel: 'email',
|
|
appId: this.appId,
|
|
recipient: options.to,
|
|
template: options.template,
|
|
subject: options.subject,
|
|
body: options.body,
|
|
data: options.data,
|
|
priority: options.priority,
|
|
externalId: options.externalId,
|
|
};
|
|
|
|
// Only include emailOptions if from or replyTo is provided
|
|
if (options.from || options.replyTo) {
|
|
payload.emailOptions = {
|
|
...(options.from && { from: options.from }),
|
|
...(options.replyTo && { replyTo: options.replyTo }),
|
|
};
|
|
}
|
|
|
|
return this.send(payload);
|
|
}
|
|
|
|
/**
|
|
* Send a push notification
|
|
*/
|
|
async sendPush(options: SendPushOptions): Promise<NotificationResponse> {
|
|
return this.send({
|
|
channel: 'push',
|
|
appId: this.appId,
|
|
userId: options.userId,
|
|
recipient: options.token,
|
|
recipients: options.tokens,
|
|
subject: options.title,
|
|
body: options.body,
|
|
data: options.data,
|
|
pushOptions: {
|
|
sound: options.sound,
|
|
badge: options.badge,
|
|
channelId: options.channelId,
|
|
},
|
|
priority: options.priority,
|
|
externalId: options.externalId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send a webhook notification
|
|
*/
|
|
async sendWebhook(options: SendWebhookOptions): Promise<NotificationResponse> {
|
|
return this.send({
|
|
channel: 'webhook',
|
|
appId: this.appId,
|
|
recipient: options.url,
|
|
data: options.body,
|
|
webhookOptions: {
|
|
method: options.method,
|
|
headers: options.headers,
|
|
timeout: options.timeout,
|
|
},
|
|
priority: options.priority,
|
|
externalId: options.externalId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Schedule an email notification
|
|
*/
|
|
async scheduleEmail(options: SendEmailOptions & ScheduleOptions): Promise<NotificationResponse> {
|
|
const payload: Record<string, unknown> = {
|
|
channel: 'email',
|
|
appId: this.appId,
|
|
recipient: options.to,
|
|
template: options.template,
|
|
subject: options.subject,
|
|
body: options.body,
|
|
data: options.data,
|
|
priority: options.priority,
|
|
externalId: options.externalId,
|
|
scheduledFor:
|
|
options.scheduledFor instanceof Date
|
|
? options.scheduledFor.toISOString()
|
|
: options.scheduledFor,
|
|
};
|
|
|
|
// Only include emailOptions if from or replyTo is provided
|
|
if (options.from || options.replyTo) {
|
|
payload.emailOptions = {
|
|
...(options.from && { from: options.from }),
|
|
...(options.replyTo && { replyTo: options.replyTo }),
|
|
};
|
|
}
|
|
|
|
return this.schedule(payload);
|
|
}
|
|
|
|
/**
|
|
* Schedule a push notification
|
|
*/
|
|
async schedulePush(options: SendPushOptions & ScheduleOptions): Promise<NotificationResponse> {
|
|
return this.schedule({
|
|
channel: 'push',
|
|
appId: this.appId,
|
|
userId: options.userId,
|
|
recipient: options.token,
|
|
recipients: options.tokens,
|
|
subject: options.title,
|
|
body: options.body,
|
|
data: options.data,
|
|
pushOptions: {
|
|
sound: options.sound,
|
|
badge: options.badge,
|
|
channelId: options.channelId,
|
|
},
|
|
priority: options.priority,
|
|
externalId: options.externalId,
|
|
scheduledFor:
|
|
options.scheduledFor instanceof Date
|
|
? options.scheduledFor.toISOString()
|
|
: options.scheduledFor,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send multiple notifications in batch
|
|
*/
|
|
async sendBatch(
|
|
notifications: Array<
|
|
| ({ type: 'email' } & SendEmailOptions)
|
|
| ({ type: 'push' } & SendPushOptions)
|
|
| ({ type: 'webhook' } & SendWebhookOptions)
|
|
>
|
|
): Promise<BatchNotificationResponse> {
|
|
const mapped = notifications.map((n) => {
|
|
if (n.type === 'email') {
|
|
return {
|
|
channel: 'email' as const,
|
|
appId: this.appId,
|
|
recipient: n.to,
|
|
template: n.template,
|
|
subject: n.subject,
|
|
body: n.body,
|
|
data: n.data,
|
|
priority: n.priority,
|
|
externalId: n.externalId,
|
|
};
|
|
} else if (n.type === 'push') {
|
|
return {
|
|
channel: 'push' as const,
|
|
appId: this.appId,
|
|
userId: n.userId,
|
|
recipient: n.token,
|
|
recipients: n.tokens,
|
|
subject: n.title,
|
|
body: n.body,
|
|
data: n.data,
|
|
priority: n.priority,
|
|
externalId: n.externalId,
|
|
};
|
|
} else {
|
|
return {
|
|
channel: 'webhook' as const,
|
|
appId: this.appId,
|
|
recipient: n.url,
|
|
data: n.body,
|
|
priority: n.priority,
|
|
externalId: n.externalId,
|
|
};
|
|
}
|
|
});
|
|
|
|
const response = await this.request<BatchNotificationResponse>('/notifications/batch', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ notifications: mapped }),
|
|
});
|
|
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Get notification status
|
|
*/
|
|
async getNotification(id: string): Promise<NotificationResponse | null> {
|
|
const response = await this.request<{ notification: NotificationResponse | null }>(
|
|
`/notifications/${id}`
|
|
);
|
|
return response.notification;
|
|
}
|
|
|
|
/**
|
|
* Cancel a pending notification
|
|
*/
|
|
async cancelNotification(id: string): Promise<NotificationResponse> {
|
|
const response = await this.request<{ notification: NotificationResponse }>(
|
|
`/notifications/${id}`,
|
|
{ method: 'DELETE' }
|
|
);
|
|
return response.notification;
|
|
}
|
|
|
|
// ==================== Templates ====================
|
|
|
|
/**
|
|
* List all templates
|
|
*/
|
|
async listTemplates(appId?: string): Promise<Template[]> {
|
|
const url = appId ? `/templates?appId=${encodeURIComponent(appId)}` : '/templates';
|
|
const response = await this.request<{ templates: Template[] }>(url);
|
|
return response.templates;
|
|
}
|
|
|
|
/**
|
|
* Get a template by slug
|
|
*/
|
|
async getTemplate(slug: string, locale = 'de-DE'): Promise<Template | null> {
|
|
const response = await this.request<{ template: Template | null }>(
|
|
`/templates/${encodeURIComponent(slug)}?locale=${encodeURIComponent(locale)}`
|
|
);
|
|
return response.template;
|
|
}
|
|
|
|
/**
|
|
* Preview a template with data
|
|
*/
|
|
async previewTemplate(
|
|
slug: string,
|
|
data: Record<string, unknown>,
|
|
locale = 'de-DE'
|
|
): Promise<RenderedTemplate | null> {
|
|
const response = await this.request<{ preview: RenderedTemplate | null }>(
|
|
`/templates/${encodeURIComponent(slug)}/preview?locale=${encodeURIComponent(locale)}`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({ data }),
|
|
}
|
|
);
|
|
return response.preview;
|
|
}
|
|
|
|
// ==================== Private Methods ====================
|
|
|
|
private async send(payload: Record<string, unknown>): Promise<NotificationResponse> {
|
|
const response = await this.request<{ notification: NotificationResponse }>(
|
|
'/notifications/send',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
}
|
|
);
|
|
return response.notification;
|
|
}
|
|
|
|
private async schedule(payload: Record<string, unknown>): Promise<NotificationResponse> {
|
|
const response = await this.request<{ notification: NotificationResponse }>(
|
|
'/notifications/schedule',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
}
|
|
);
|
|
return response.notification;
|
|
}
|
|
|
|
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
const url = `${this.serviceUrl}/api/v1${path}`;
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Service-Key': this.serviceKey,
|
|
...options.headers,
|
|
},
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
const message =
|
|
(errorData as { error?: { message?: string } }).error?.message ||
|
|
`HTTP ${response.status}`;
|
|
throw new Error(`NotifyClient error: ${message}`);
|
|
}
|
|
|
|
return response.json() as Promise<T>;
|
|
} catch (error) {
|
|
clearTimeout(timeoutId);
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
throw new Error('NotifyClient error: Request timeout');
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
}
|