Initial commit

This commit is contained in:
siliconsolder committed 2026-09-07 09:54:30 +00:00
commit 38c24fc9dc
166 files changed
+22002

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
import { fileURLToPath } from 'node:url'
import path from 'node:path'
function requireEnv(name: string, fallback?: string): string {
const value = process.env[name] ?? fallback
if (value === undefined) {
throw new Error(`Missing required environment variable: ${name}`)
}
return value
}
function splitCsv(value: string): string[] {
return value
.split(',')
.map((part) => part.trim())
.filter(Boolean)
}
const serverSrcDir = fileURLToPath(new URL('.', import.meta.url))
const defaultStaticWebDir = path.resolve(serverSrcDir, '../../web/dist')
export const config = {
nodeEnv: process.env.NODE_ENV ?? 'development',
isProduction: process.env.NODE_ENV === 'production',
portWeb: Number(process.env.PORT_WEB ?? 8080),
portIngest: Number(process.env.PORT_INGEST ?? 8081),
publicBaseUrl: requireEnv('PUBLIC_BASE_URL', 'http://localhost:8080'),
databaseUrl: requireEnv(
'DATABASE_URL',
'postgres://notizarr:notizarr_dev_password@localhost:5432/notizarr',
),
logLevel: process.env.LOG_LEVEL ?? 'info',
authMode: (process.env.AUTH_MODE ?? 'disabled') as 'oidc' | 'proxy' | 'disabled',
oidc: {
issuerUrl: process.env.OIDC_ISSUER_URL ?? '',
clientId: process.env.OIDC_CLIENT_ID ?? '',
clientSecret: process.env.OIDC_CLIENT_SECRET ?? '',
redirectUrl: process.env.OIDC_REDIRECT_URL ?? '',
scopes: process.env.OIDC_SCOPES ?? 'openid profile email',
},
authProxy: {
userHeader: (process.env.AUTH_PROXY_USER_HEADER ?? '').toLowerCase(),
emailHeader: (process.env.AUTH_PROXY_EMAIL_HEADER ?? '').toLowerCase(),
groupsHeader: (process.env.AUTH_PROXY_GROUPS_HEADER ?? '').toLowerCase(),
},
trustedProxyCidrs: splitCsv(process.env.TRUSTED_PROXY_CIDRS ?? ''),
ingestionAllowedCidrs: splitCsv(
process.env.INGESTION_ALLOWED_CIDRS ?? '10.0.0.0/8,172.16.0.0/12,192.168.0.0/16',
),
retentionDefaultDays: Number(process.env.RETENTION_DEFAULT_DAYS ?? 90),
rateLimit: {
sustainedPerMinute: Number(process.env.RATE_LIMIT_SUSTAINED_PER_MIN ?? 60),
burstPer10Seconds: Number(process.env.RATE_LIMIT_BURST_PER_10S ?? 20),
},
docsEnabled: (process.env.DOCS_ENABLED ?? 'true') !== 'false',
vapidSubject: process.env.VAPID_SUBJECT ?? 'mailto:admin@localhost',
staticWebDir: process.env.STATIC_WEB_DIR ?? defaultStaticWebDir,
// Repo root's backups/ dir by default — same three-levels-up path from
// apps/server/src (dev, via tsx) or apps/server/dist (prod build) either
// way, since both sit at the same depth under apps/server.
backupsDir: process.env.BACKUPS_DIR ?? path.resolve(serverSrcDir, '../../../backups'),
}
export type Config = typeof config
+17
View File
@@ -0,0 +1,17 @@
import { drizzle } from 'drizzle-orm/node-postgres'
import pg from 'pg'
import { config } from '../config.js'
import * as schema from './schema.js'
export const pool = new pg.Pool({ connectionString: config.databaseUrl })
export const db = drizzle(pool, { schema })
export async function checkDbConnection(): Promise<boolean> {
try {
await pool.query('SELECT 1')
return true
} catch {
return false
}
}
+36
View File
@@ -0,0 +1,36 @@
import { existsSync, mkdirSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { drizzle } from 'drizzle-orm/node-postgres'
import { migrate } from 'drizzle-orm/node-postgres/migrator'
import { pool } from './client.js'
const migrationsFolder = path.resolve(fileURLToPath(new URL('.', import.meta.url)), 'migrations')
// Runs on every server boot (see src/index.ts) so `docker compose up -d`
// always ends with an up-to-date schema, per docs/phase1/01-architecture.md
// ("On boot: run pending DB migrations"). Also runnable standalone:
// pnpm --filter @notizarr/server db:migrate
export async function runMigrations(): Promise<void> {
// Guards a fresh clone before `drizzle-kit generate` has ever run —
// migrate() expects the folder to exist even if it's empty.
if (!existsSync(migrationsFolder)) {
mkdirSync(migrationsFolder, { recursive: true })
}
const db = drizzle(pool)
await migrate(db, { migrationsFolder })
}
const isMainModule = process.argv[1] && import.meta.url === `file://${process.argv[1]}`
if (isMainModule) {
runMigrations()
.then(() => {
console.log('Migrations applied successfully.')
process.exit(0)
})
.catch((err: unknown) => {
console.error('Migration failed:', err)
process.exit(1)
})
}
@@ -0,0 +1,265 @@
DO $$ BEGIN
CREATE TYPE "public"."push_delivery_status" AS ENUM('pending', 'accepted_by_push_service', 'failed', 'invalid_subscription', 'expired');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
CREATE TYPE "public"."user_role" AS ENUM('admin', 'user');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "api_token_topics" (
"token_id" uuid NOT NULL,
"topic_id" uuid NOT NULL,
CONSTRAINT "api_token_topics_token_id_topic_id_pk" PRIMARY KEY("token_id","topic_id")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "api_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"token_hash" text NOT NULL,
"token_prefix" text NOT NULL,
"allowed_source" text,
"allowed_cidrs" text[],
"expires_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_used_at" timestamp with time zone,
"usage_count" bigint DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "audit_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"actor_user_id" uuid,
"actor_token_id" uuid,
"action" text NOT NULL,
"target_type" text,
"target_id" text,
"metadata" jsonb,
"ip_address" "inet",
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "notifications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"topic_id" uuid NOT NULL,
"source" text NOT NULL,
"title" text NOT NULL,
"message" text NOT NULL,
"external_url" text,
"image_url" text,
"attachment_filename" text,
"attachment_content_type" text,
"attachment_size_bytes" bigint,
"deduplication_key" text,
"occurrence_count" integer DEFAULT 1 NOT NULL,
"raw_priority" text,
"sender_token_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "push_delivery_attempts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"notification_id" uuid NOT NULL,
"push_subscription_id" uuid NOT NULL,
"status" "push_delivery_status" NOT NULL,
"http_status" integer,
"error_message" text,
"retry_count" integer DEFAULT 0 NOT NULL,
"attempted_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "push_subscriptions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"endpoint" text NOT NULL,
"p256dh" text NOT NULL,
"auth" text NOT NULL,
"user_agent" text,
"device_label" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"disabled_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "system_settings" (
"key" text PRIMARY KEY NOT NULL,
"value" jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "topic_members" (
"topic_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
CONSTRAINT "topic_members_topic_id_user_id_pk" PRIMARY KEY("topic_id","user_id")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "topics" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"display_name" text NOT NULL,
"description" text,
"icon" text,
"accent_color" text,
"retention_days" integer,
"restricted" boolean DEFAULT false NOT NULL,
"created_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"archived_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "user_notification_state" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"notification_id" uuid NOT NULL,
"read" boolean DEFAULT false NOT NULL,
"read_at" timestamp with time zone,
"starred" boolean DEFAULT false NOT NULL,
"starred_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "user_topic_subscriptions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"topic_id" uuid NOT NULL,
"push_enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"display_name" text NOT NULL,
"avatar_url" text,
"role" "user_role" DEFAULT 'user' NOT NULL,
"auth_subject" text NOT NULL,
"auth_issuer" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_login_at" timestamp with time zone
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "api_token_topics" ADD CONSTRAINT "api_token_topics_token_id_api_tokens_id_fk" FOREIGN KEY ("token_id") REFERENCES "public"."api_tokens"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "api_token_topics" ADD CONSTRAINT "api_token_topics_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_token_id_api_tokens_id_fk" FOREIGN KEY ("actor_token_id") REFERENCES "public"."api_tokens"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_sender_token_id_api_tokens_id_fk" FOREIGN KEY ("sender_token_id") REFERENCES "public"."api_tokens"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "push_delivery_attempts" ADD CONSTRAINT "push_delivery_attempts_notification_id_notifications_id_fk" FOREIGN KEY ("notification_id") REFERENCES "public"."notifications"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "push_delivery_attempts" ADD CONSTRAINT "push_delivery_attempts_push_subscription_id_push_subscriptions_id_fk" FOREIGN KEY ("push_subscription_id") REFERENCES "public"."push_subscriptions"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "topic_members" ADD CONSTRAINT "topic_members_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "topic_members" ADD CONSTRAINT "topic_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "topics" ADD CONSTRAINT "topics_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_notification_state" ADD CONSTRAINT "user_notification_state_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_notification_state" ADD CONSTRAINT "user_notification_state_notification_id_notifications_id_fk" FOREIGN KEY ("notification_id") REFERENCES "public"."notifications"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_topic_subscriptions" ADD CONSTRAINT "user_topic_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_topic_subscriptions" ADD CONSTRAINT "user_topic_subscriptions_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "api_tokens_token_hash_key" ON "api_tokens" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "audit_log_created_idx" ON "audit_log" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "audit_log_action_idx" ON "audit_log" USING btree ("action");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "notifications_topic_created_idx" ON "notifications" USING btree ("topic_id","created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "notifications_dedup_idx" ON "notifications" USING btree ("deduplication_key","topic_id","created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "notifications_created_idx" ON "notifications" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "push_delivery_attempts_notification_idx" ON "push_delivery_attempts" USING btree ("notification_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "push_delivery_attempts_subscription_idx" ON "push_delivery_attempts" USING btree ("push_subscription_id","attempted_at");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "push_subscriptions_endpoint_key" ON "push_subscriptions" USING btree ("endpoint");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "topics_slug_key" ON "topics" USING btree ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "user_notification_state_user_notification_key" ON "user_notification_state" USING btree ("user_id","notification_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_notification_state_user_read_idx" ON "user_notification_state" USING btree ("user_id","read");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_notification_state_user_starred_idx" ON "user_notification_state" USING btree ("user_id","starred");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "user_topic_subscriptions_user_topic_key" ON "user_topic_subscriptions" USING btree ("user_id","topic_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "users_email_key" ON "users" USING btree ("email");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "users_issuer_subject_key" ON "users" USING btree ("auth_issuer","auth_subject");
@@ -0,0 +1 @@
ALTER TABLE "user_topic_subscriptions" ADD COLUMN "muted_until" timestamp with time zone;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1788720295278,
"tag": "0000_cuddly_tarot",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1788772064420,
"tag": "0001_purple_firestar",
"breakpoints": true
}
]
}
@@ -0,0 +1,50 @@
import { randomUUID } from 'node:crypto'
import { eq } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db, pool } from './client.js'
import { runMigrations } from './migrate.js'
import { notifications, topics } from './schema.js'
// Integration tests — need a real, migrated Postgres reachable via
// DATABASE_URL. The dev compose stack provides one; run these with:
// docker compose -f docker/docker-compose.yml exec notizarr \
// pnpm --filter @notizarr/server test
describe('schema constraints', () => {
const testSlug = `test-topic-${randomUUID()}`
let topicId: string
beforeAll(async () => {
await runMigrations()
const [topic] = await db
.insert(topics)
.values({ slug: testSlug, displayName: 'Test Topic' })
.returning()
topicId = topic!.id
})
afterAll(async () => {
await db.delete(topics).where(eq(topics.slug, testSlug))
await pool.end()
})
it('rejects a duplicate topic slug', async () => {
await expect(
db.insert(topics).values({ slug: testSlug, displayName: 'Duplicate' }),
).rejects.toThrow()
})
it('cascades notification deletion when its topic is deleted', async () => {
const [notification] = await db
.insert(notifications)
.values({ topicId, source: 'test', title: 'Hello', message: 'World' })
.returning()
await db.delete(topics).where(eq(topics.id, topicId))
const remaining = await db
.select()
.from(notifications)
.where(eq(notifications.id, notification!.id))
expect(remaining).toHaveLength(0)
})
})
+293
View File
@@ -0,0 +1,293 @@
// Drizzle schema matching docs/phase1/03-database-model.md exactly. One
// table per section of that document, in the same order, so the two stay
// easy to diff against each other.
//
// Full-text search (the `search_vector` / GIN index called out in the
// design doc) is intentionally deferred to Phase 5 when search is actually
// implemented, rather than guessed at here.
import {
bigint,
boolean,
customType,
index,
integer,
jsonb,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core'
const inet = customType<{ data: string }>({
dataType() {
return 'inet'
},
})
export const roleEnum = pgEnum('user_role', ['admin', 'user'])
export const pushDeliveryStatusEnum = pgEnum('push_delivery_status', [
'pending',
'accepted_by_push_service',
'failed',
'invalid_subscription',
'expired',
])
export const users = pgTable(
'users',
{
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull(),
displayName: text('display_name').notNull(),
avatarUrl: text('avatar_url'),
role: roleEnum('role').notNull().default('user'),
authSubject: text('auth_subject').notNull(),
authIssuer: text('auth_issuer'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
},
(table) => [
uniqueIndex('users_email_key').on(table.email),
uniqueIndex('users_issuer_subject_key').on(table.authIssuer, table.authSubject),
],
)
export const topics = pgTable(
'topics',
{
id: uuid('id').primaryKey().defaultRandom(),
slug: text('slug').notNull(),
displayName: text('display_name').notNull(),
description: text('description'),
icon: text('icon'),
accentColor: text('accent_color'),
retentionDays: integer('retention_days'),
restricted: boolean('restricted').notNull().default(false),
createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
archivedAt: timestamp('archived_at', { withTimezone: true }),
},
(table) => [uniqueIndex('topics_slug_key').on(table.slug)],
)
// Only consulted when topics.restricted = true.
export const topicMembers = pgTable(
'topic_members',
{
topicId: uuid('topic_id')
.notNull()
.references(() => topics.id, { onDelete: 'cascade' }),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
},
(table) => [primaryKey({ columns: [table.topicId, table.userId] })],
)
// Absence of a row = default push_enabled=true for a topic the user can see.
export const userTopicSubscriptions = pgTable(
'user_topic_subscriptions',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
topicId: uuid('topic_id')
.notNull()
.references(() => topics.id, { onDelete: 'cascade' }),
pushEnabled: boolean('push_enabled').notNull().default(true),
// Temporary snooze — independent of pushEnabled, so turning this off
// doesn't touch the user's actual on/off preference and just expires
// on its own. NULL or a past timestamp means "not snoozed".
mutedUntil: timestamp('muted_until', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('user_topic_subscriptions_user_topic_key').on(table.userId, table.topicId),
],
)
export const apiTokens = pgTable(
'api_tokens',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
tokenHash: text('token_hash').notNull(),
tokenPrefix: text('token_prefix').notNull(),
allowedSource: text('allowed_source'),
allowedCidrs: text('allowed_cidrs').array(),
expiresAt: timestamp('expires_at', { withTimezone: true }),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
usageCount: bigint('usage_count', { mode: 'number' }).notNull().default(0),
},
(table) => [uniqueIndex('api_tokens_token_hash_key').on(table.tokenHash)],
)
// Join table: which topics a token may publish to. Empty = all topics.
export const apiTokenTopics = pgTable(
'api_token_topics',
{
tokenId: uuid('token_id')
.notNull()
.references(() => apiTokens.id, { onDelete: 'cascade' }),
topicId: uuid('topic_id')
.notNull()
.references(() => topics.id, { onDelete: 'cascade' }),
},
(table) => [primaryKey({ columns: [table.tokenId, table.topicId] })],
)
export const notifications = pgTable(
'notifications',
{
id: uuid('id').primaryKey().defaultRandom(),
topicId: uuid('topic_id')
.notNull()
.references(() => topics.id, { onDelete: 'cascade' }),
source: text('source').notNull(),
title: text('title').notNull(),
message: text('message').notNull(),
externalUrl: text('external_url'),
imageUrl: text('image_url'),
attachmentFilename: text('attachment_filename'),
attachmentContentType: text('attachment_content_type'),
attachmentSizeBytes: bigint('attachment_size_bytes', { mode: 'number' }),
deduplicationKey: text('deduplication_key'),
occurrenceCount: integer('occurrence_count').notNull().default(1),
rawPriority: text('raw_priority'),
senderTokenId: uuid('sender_token_id').references(() => apiTokens.id, {
onDelete: 'set null',
}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('notifications_topic_created_idx').on(table.topicId, table.createdAt),
index('notifications_dedup_idx').on(
table.deduplicationKey,
table.topicId,
table.createdAt,
),
index('notifications_created_idx').on(table.createdAt),
],
)
// Per-user read/star state — notifications are shared rows across every
// user who can see the topic.
export const userNotificationState = pgTable(
'user_notification_state',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
notificationId: uuid('notification_id')
.notNull()
.references(() => notifications.id, { onDelete: 'cascade' }),
read: boolean('read').notNull().default(false),
readAt: timestamp('read_at', { withTimezone: true }),
starred: boolean('starred').notNull().default(false),
starredAt: timestamp('starred_at', { withTimezone: true }),
},
(table) => [
uniqueIndex('user_notification_state_user_notification_key').on(
table.userId,
table.notificationId,
),
index('user_notification_state_user_read_idx').on(table.userId, table.read),
index('user_notification_state_user_starred_idx').on(table.userId, table.starred),
],
)
export const pushSubscriptions = pgTable(
'push_subscriptions',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
endpoint: text('endpoint').notNull(),
p256dh: text('p256dh').notNull(),
auth: text('auth').notNull(),
userAgent: text('user_agent'),
deviceLabel: text('device_label'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
disabledAt: timestamp('disabled_at', { withTimezone: true }),
},
(table) => [uniqueIndex('push_subscriptions_endpoint_key').on(table.endpoint)],
)
export const pushDeliveryAttempts = pgTable(
'push_delivery_attempts',
{
id: uuid('id').primaryKey().defaultRandom(),
notificationId: uuid('notification_id')
.notNull()
.references(() => notifications.id, { onDelete: 'cascade' }),
pushSubscriptionId: uuid('push_subscription_id')
.notNull()
.references(() => pushSubscriptions.id, { onDelete: 'cascade' }),
status: pushDeliveryStatusEnum('status').notNull(),
httpStatus: integer('http_status'),
errorMessage: text('error_message'),
retryCount: integer('retry_count').notNull().default(0),
attemptedAt: timestamp('attempted_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('push_delivery_attempts_notification_idx').on(table.notificationId),
index('push_delivery_attempts_subscription_idx').on(
table.pushSubscriptionId,
table.attemptedAt,
),
],
)
export const auditLog = pgTable(
'audit_log',
{
id: uuid('id').primaryKey().defaultRandom(),
actorUserId: uuid('actor_user_id').references(() => users.id, { onDelete: 'set null' }),
actorTokenId: uuid('actor_token_id').references(() => apiTokens.id, {
onDelete: 'set null',
}),
action: text('action').notNull(),
targetType: text('target_type'),
targetId: text('target_id'),
metadata: jsonb('metadata'),
ipAddress: inet('ip_address'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('audit_log_created_idx').on(table.createdAt),
index('audit_log_action_idx').on(table.action),
],
)
// Singleton key/value store: VAPID keypair, global retention default,
// rate-limit overrides.
export const systemSettings = pgTable('system_settings', {
key: text('key').primaryKey(),
value: jsonb('value').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsert
export type Topic = typeof topics.$inferSelect
export type NewTopic = typeof topics.$inferInsert
export type Notification = typeof notifications.$inferSelect
export type NewNotification = typeof notifications.$inferInsert
export type ApiToken = typeof apiTokens.$inferSelect
export type PushSubscription = typeof pushSubscriptions.$inferSelect
export type UserNotificationState = typeof userNotificationState.$inferSelect
+34
View File
@@ -0,0 +1,34 @@
import { eq } from 'drizzle-orm'
import { db } from './client.js'
import { systemSettings } from './schema.js'
export async function getSetting<T>(key: string): Promise<T | null> {
const [row] = await db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, key))
.limit(1)
return row ? (row.value as T) : null
}
export async function setSetting<T>(key: string, value: T): Promise<void> {
await db
.insert(systemSettings)
.values({ key, value: value as never })
.onConflictDoUpdate({
target: systemSettings.key,
set: { value: value as never, updatedAt: new Date() },
})
}
// Generates a value on first call and persists it, so every subsequent
// boot (including after a redeploy) sees the same value — the same pattern
// used for the VAPID keypair in Phase 7 (docs/phase1/05-pwa-web-push-design.md).
export async function getOrCreateSetting<T>(key: string, generate: () => T): Promise<T> {
const existing = await getSetting<T>(key)
if (existing !== null) return existing
const value = generate()
await setSetting(key, value)
return value
}
+65
View File
@@ -0,0 +1,65 @@
import { config } from './config.js'
import { checkDbConnection } from './db/client.js'
import { runMigrations } from './db/migrate.js'
import { buildIngestApp } from './listeners/ingest.js'
import { buildWebApp } from './listeners/web.js'
import { startBackupJob } from './modules/admin/backup.js'
import { startRetentionCleanupJob } from './modules/notifications/retention.js'
// depends_on: condition: service_healthy (docker-compose.yml) only gets
// evaluated by `docker compose up` — it does NOT apply when Docker's own
// restart policy brings containers back after a daemon/host reboot, where
// each container restarts independently with no guaranteed order. Retrying
// here means a Postgres-not-ready-yet race self-heals instead of crashing
// the process once and sitting dead (tsx watch only restarts on a file
// change, not after a runtime crash — see docs/phase1/06-docker-unraid-design.md).
async function waitForDatabase(maxAttempts = 20, delayMs = 3000): Promise<void> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (await checkDbConnection()) return
console.log(
`Database not reachable yet (attempt ${attempt}/${maxAttempts}), retrying in ${delayMs}ms...`,
)
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
throw new Error(`Could not reach the database after ${maxAttempts} attempts`)
}
async function main() {
if (config.authMode === 'disabled' && config.isProduction) {
throw new Error(
'AUTH_MODE=disabled grants implicit admin access to every request and is only ' +
'for local development. Refusing to start with NODE_ENV=production. Use oidc or proxy.',
)
}
await waitForDatabase()
await runMigrations()
const webApp = await buildWebApp()
const ingestApp = await buildIngestApp()
await webApp.listen({ port: config.portWeb, host: '0.0.0.0' })
webApp.log.info(`notizarr web listener up on :${config.portWeb}`)
await ingestApp.listen({ port: config.portIngest, host: '0.0.0.0' })
ingestApp.log.info(`notizarr ingest listener up on :${config.portIngest}`)
const retentionTimer = startRetentionCleanupJob((err) => webApp.log.error(err, 'retention cleanup failed'))
const backupTimer = startBackupJob((err) => webApp.log.error(err, 'scheduled backup failed'))
const shutdown = async (signal: string) => {
webApp.log.info(`received ${signal}, shutting down`)
clearInterval(retentionTimer)
clearInterval(backupTimer)
await Promise.all([webApp.close(), ingestApp.close()])
process.exit(0)
}
process.on('SIGTERM', () => void shutdown('SIGTERM'))
process.on('SIGINT', () => void shutdown('SIGINT'))
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { isIpInAnyCidr } from './cidr.js'
describe('isIpInAnyCidr', () => {
it('matches an address inside a /8', () => {
expect(isIpInAnyCidr('10.1.2.3', ['10.0.0.0/8'])).toBe(true)
})
it('rejects an address outside all ranges', () => {
expect(isIpInAnyCidr('8.8.8.8', ['10.0.0.0/8', '192.168.0.0/16'])).toBe(false)
})
it('matches an exact /32', () => {
expect(isIpInAnyCidr('127.0.0.1', ['127.0.0.1/32'])).toBe(true)
expect(isIpInAnyCidr('127.0.0.2', ['127.0.0.1/32'])).toBe(false)
})
it('treats 0.0.0.0/0 as match-all', () => {
expect(isIpInAnyCidr('203.0.113.5', ['0.0.0.0/0'])).toBe(true)
})
it('normalizes IPv4-mapped IPv6 addresses', () => {
expect(isIpInAnyCidr('::ffff:192.168.1.50', ['192.168.0.0/16'])).toBe(true)
})
it('returns false when no CIDRs are configured', () => {
expect(isIpInAnyCidr('10.0.0.1', [])).toBe(false)
})
})
+51
View File
@@ -0,0 +1,51 @@
// Minimal, dependency-free CIDR matching. IPv4-focused (the realistic case
// for a LAN homelab); IPv6 support is "exact address" or an explicit "::/0"
// wildcard only — full IPv6 prefix matching can be added later if it's ever
// actually needed.
function normalizeIp(ip: string): string {
// Node reports IPv4 clients as "::ffff:x.x.x.x" on a dual-stack listener.
const v4mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)
// The pattern has exactly one capture group, so a successful match always
// populates it.
return v4mapped ? v4mapped[1]! : ip
}
function ipv4ToInt(ip: string): number | null {
const parts = ip.split('.')
if (parts.length !== 4) return null
let result = 0
for (const part of parts) {
const n = Number(part)
if (!Number.isInteger(n) || n < 0 || n > 255) return null
result = (result << 8) | n
}
return result >>> 0
}
function matchesCidr(ip: string, cidr: string): boolean {
const trimmed = cidr.trim()
if (trimmed === '0.0.0.0/0' || trimmed === '::/0') return true
const [rangeIp, prefixStr] = trimmed.split('/')
const ipInt = ipv4ToInt(ip)
const rangeInt = rangeIp ? ipv4ToInt(rangeIp) : null
if (ipInt === null || rangeInt === null) {
// Not IPv4 on one side (e.g. an IPv6 literal) — fall back to exact match.
return ip === rangeIp
}
const prefixLength = prefixStr === undefined ? 32 : Number(prefixStr)
if (!Number.isInteger(prefixLength) || prefixLength < 0 || prefixLength > 32) return false
if (prefixLength === 0) return true
const mask = prefixLength === 32 ? 0xffffffff : (~0 << (32 - prefixLength)) >>> 0
return (ipInt & mask) === (rangeInt & mask)
}
export function isIpInAnyCidr(ip: string, cidrs: readonly string[]): boolean {
if (cidrs.length === 0) return false
const normalized = normalizeIp(ip)
return cidrs.some((cidr) => matchesCidr(normalized, cidr))
}
+35
View File
@@ -0,0 +1,35 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
// Small AES-256-GCM envelope for the session cookie payload — deliberately
// not using a library like @fastify/secure-session here, since its
// sodium-native dependency has no prebuilt binary for Alpine/musl (see the
// comment in modules/auth/session.ts). Node's built-in crypto needs no
// native compilation.
const ALGORITHM = 'aes-256-gcm'
const IV_LENGTH = 12
const AUTH_TAG_LENGTH = 16
export function encryptJson(key: Buffer, data: unknown): string {
const iv = randomBytes(IV_LENGTH)
const cipher = createCipheriv(ALGORITHM, key, iv)
const plaintext = Buffer.from(JSON.stringify(data), 'utf8')
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()])
const authTag = cipher.getAuthTag()
return Buffer.concat([iv, authTag, encrypted]).toString('base64url')
}
export function decryptJson<T>(key: Buffer, value: string): T | null {
try {
const buf = Buffer.from(value, 'base64url')
const iv = buf.subarray(0, IV_LENGTH)
const authTag = buf.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH)
const encrypted = buf.subarray(IV_LENGTH + AUTH_TAG_LENGTH)
const decipher = createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(authTag)
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
return JSON.parse(decrypted.toString('utf8')) as T
} catch {
// Tampered, expired-key-rotated, or malformed cookie — treat as no session.
return null
}
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { isQuietPriority, isWithinQuietHours, resolvePriority } from './priority.js'
describe('resolvePriority', () => {
it('defaults to 3 when no value is given', () => {
expect(resolvePriority(null)).toBe(3)
expect(resolvePriority(undefined)).toBe(3)
expect(resolvePriority('')).toBe(3)
})
it('maps ntfy word aliases to their numeric levels', () => {
expect(resolvePriority('min')).toBe(1)
expect(resolvePriority('low')).toBe(2)
expect(resolvePriority('default')).toBe(3)
expect(resolvePriority('high')).toBe(4)
expect(resolvePriority('urgent')).toBe(5)
expect(resolvePriority('max')).toBe(5)
})
it('is case-insensitive and trims whitespace on word aliases', () => {
expect(resolvePriority('MIN')).toBe(1)
expect(resolvePriority(' min ')).toBe(1)
})
it('parses bare numeric strings', () => {
expect(resolvePriority('1')).toBe(1)
expect(resolvePriority('5')).toBe(5)
})
it('falls back to default for unrecognized input', () => {
expect(resolvePriority('not-a-priority')).toBe(3)
})
})
describe('isQuietPriority', () => {
it('is true only at min/1 and below', () => {
expect(isQuietPriority('min')).toBe(true)
expect(isQuietPriority('1')).toBe(true)
expect(isQuietPriority('low')).toBe(false)
expect(isQuietPriority('2')).toBe(false)
expect(isQuietPriority('default')).toBe(false)
expect(isQuietPriority(null)).toBe(false)
})
})
describe('isWithinQuietHours', () => {
it('handles a same-day window', () => {
const noon = new Date('2026-01-01T12:00:00Z')
expect(isWithinQuietHours('09:00', '17:00', 'UTC', noon)).toBe(true)
const evening = new Date('2026-01-01T20:00:00Z')
expect(isWithinQuietHours('09:00', '17:00', 'UTC', evening)).toBe(false)
})
it('handles a window that wraps past midnight', () => {
const lateNight = new Date('2026-01-01T23:30:00Z')
expect(isWithinQuietHours('23:00', '07:00', 'UTC', lateNight)).toBe(true)
const earlyMorning = new Date('2026-01-01T05:00:00Z')
expect(isWithinQuietHours('23:00', '07:00', 'UTC', earlyMorning)).toBe(true)
const midday = new Date('2026-01-01T12:00:00Z')
expect(isWithinQuietHours('23:00', '07:00', 'UTC', midday)).toBe(false)
})
it('treats an identical start/end as always off', () => {
const anytime = new Date('2026-01-01T12:00:00Z')
expect(isWithinQuietHours('09:00', '09:00', 'UTC', anytime)).toBe(false)
})
it('resolves the window in the given timezone, not UTC', () => {
// 22:30 in Stockholm (UTC+1 in January) is 21:30 UTC — inside a
// 22:00-06:00 window in Stockholm time, but not if evaluated in UTC.
const time = new Date('2026-01-01T21:30:00Z')
expect(isWithinQuietHours('22:00', '06:00', 'Europe/Stockholm', time)).toBe(true)
expect(isWithinQuietHours('22:00', '06:00', 'UTC', time)).toBe(false)
})
})
+58
View File
@@ -0,0 +1,58 @@
// ntfy-style priority scale (docs/migration-ntfy.md) — reused here so a
// value already familiar to anyone migrating from ntfy behaves the way
// they'd expect: "min"/1 means "log it, don't buzz the phone."
const PRIORITY_ALIASES: Record<string, number> = {
min: 1,
low: 2,
default: 3,
high: 4,
urgent: 5,
max: 5,
}
const DEFAULT_PRIORITY = 3
const MIN_PRIORITY_NO_PUSH = 1
export function resolvePriority(raw: string | null | undefined): number {
if (!raw) return DEFAULT_PRIORITY
const trimmed = raw.trim().toLowerCase()
// The `in` check above guarantees the key exists.
if (trimmed in PRIORITY_ALIASES) return PRIORITY_ALIASES[trimmed]!
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : DEFAULT_PRIORITY
}
// "min" priority notifications are stored and shown in-app like any other
// — they just don't trigger a push, so a noisy automation can stay in its
// normal topic instead of needing a dedicated quiet topic.
export function isQuietPriority(raw: string | null | undefined): boolean {
return resolvePriority(raw) <= MIN_PRIORITY_NO_PUSH
}
// Household-wide "quiet hours" window (Admin > System) — true when `now`
// falls inside [start, end) in the given IANA timezone. Handles a window
// that wraps past midnight (e.g. 23:00-07:00) by checking OR instead of
// AND once start > end.
export function isWithinQuietHours(start: string, end: string, timezone: string, now = new Date()): boolean {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(now)
const hour = Number(parts.find((p) => p.type === 'hour')?.value ?? '0')
const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? '0')
const nowMinutes = hour * 60 + minute
const [startH, startM] = start.split(':').map(Number)
const [endH, endM] = end.split(':').map(Number)
const startMinutes = (startH ?? 0) * 60 + (startM ?? 0)
const endMinutes = (endH ?? 0) * 60 + (endM ?? 0)
if (startMinutes === endMinutes) return false
if (startMinutes < endMinutes) {
return nowMinutes >= startMinutes && nowMinutes < endMinutes
}
return nowMinutes >= startMinutes || nowMinutes < endMinutes
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { createDualWindowLimiter, TokenBucket } from './rate-limiter.js'
describe('TokenBucket', () => {
it('allows up to capacity requests, then rejects', () => {
const bucket = new TokenBucket(3, 60_000)
expect(bucket.tryConsume('a')).toBe(true)
expect(bucket.tryConsume('a')).toBe(true)
expect(bucket.tryConsume('a')).toBe(true)
expect(bucket.tryConsume('a')).toBe(false)
})
it('tracks separate keys independently', () => {
const bucket = new TokenBucket(1, 60_000)
expect(bucket.tryConsume('a')).toBe(true)
expect(bucket.tryConsume('b')).toBe(true)
expect(bucket.tryConsume('a')).toBe(false)
})
})
describe('createDualWindowLimiter', () => {
it('rejects once the smaller (burst) window is exhausted', () => {
const limiter = createDualWindowLimiter(100, 60_000, 2, 10_000)
expect(limiter.check('k')).toBe(true)
expect(limiter.check('k')).toBe(true)
expect(limiter.check('k')).toBe(false)
})
it('does not drain the sustained window on a burst-only rejection', () => {
const limiter = createDualWindowLimiter(100, 60_000, 1, 10_000)
expect(limiter.check('k')).toBe(true)
// Burst window is now empty; several more calls should all be
// rejected by burst without touching the (still nearly full) sustained
// bucket.
for (let i = 0; i < 5; i++) {
expect(limiter.check('k')).toBe(false)
}
})
})
+71
View File
@@ -0,0 +1,71 @@
// In-memory token bucket rate limiter. Single-instance app (see
// docs/phase1/01-architecture.md — no Redis), so process memory is a fine
// place for this; it resets on restart, which is an acceptable trade-off
// for a homelab-scale rate limiter.
interface Bucket {
tokens: number
lastRefillMs: number
}
export class TokenBucket {
private buckets = new Map<string, Bucket>()
constructor(
private readonly capacity: number,
private readonly windowMs: number,
) {}
private refillRatePerMs(): number {
return this.capacity / this.windowMs
}
private currentTokens(key: string, now: number): { bucket: Bucket; tokens: number } {
const bucket = this.buckets.get(key) ?? { tokens: this.capacity, lastRefillMs: now }
const elapsed = now - bucket.lastRefillMs
const tokens = Math.min(this.capacity, bucket.tokens + elapsed * this.refillRatePerMs())
return { bucket, tokens }
}
/** Non-mutating: how many tokens would be available right now. */
peek(key: string): number {
return this.currentTokens(key, Date.now()).tokens
}
/** Spends one token. Only call after peek() (or checkRateLimit) confirmed room. */
consume(key: string): void {
const now = Date.now()
const { bucket, tokens } = this.currentTokens(key, now)
bucket.tokens = tokens - 1
bucket.lastRefillMs = now
this.buckets.set(key, bucket)
}
/** Checks and, if there's room, consumes in one step. */
tryConsume(key: string): boolean {
if (this.peek(key) < 1) return false
this.consume(key)
return true
}
}
export function createDualWindowLimiter(
sustainedCapacity: number,
sustainedWindowMs: number,
burstCapacity: number,
burstWindowMs: number,
) {
const sustained = new TokenBucket(sustainedCapacity, sustainedWindowMs)
const burst = new TokenBucket(burstCapacity, burstWindowMs)
// Checks both windows for `key` and consumes one token from each only if
// BOTH have room — a request that fails one window never silently drains
// the other.
return {
check(key: string): boolean {
if (burst.peek(key) < 1 || sustained.peek(key) < 1) return false
burst.consume(key)
sustained.consume(key)
return true
},
}
}
+46
View File
@@ -0,0 +1,46 @@
import Fastify, { type FastifyInstance } from 'fastify'
import { loggerOptions } from '../logger.js'
import { registerNotificationIngestRoutes } from '../modules/notifications/ingest-routes.js'
import { registerNtfyCompatRoutes } from '../modules/notifications/ntfy-routes.js'
import { registerWebhookRoutes } from '../modules/notifications/webhook-routes.js'
// Listener B — LAN-only ingestion API, bearer-token authenticated.
// See docs/phase1/01-architecture.md and 02-threat-model.md: this listener
// never serves the SPA and never accepts session cookies.
export async function buildIngestApp(): Promise<FastifyInstance> {
// Tighter than the web listener's default — a legitimate notification
// plus headers comfortably fits well under this; docs/phase1/02-threat-model.md,
// "Denial of service via large payloads".
const app = Fastify({
logger: loggerOptions,
bodyLimit: 256 * 1024,
// ingest-routes.ts's `priority` field accepts a JSON string or number
// (an unquoted Jinja template value renders as the latter) — Ajv's
// strict mode otherwise warns on that `type: [...]` union.
ajv: { customOptions: { allowUnionTypes: true } },
})
// The canonical/webhook routes expect JSON; the ntfy-compat route also
// accepts a raw-text body (typical `curl -d "message" .../topic` usage).
// Fastify's built-in application/json parser still takes precedence over
// this catch-all for content types it explicitly recognizes.
app.addContentTypeParser('*', { parseAs: 'string' }, (_req, body, done) => {
done(null, body)
})
app.get('/health', async () => ({ status: 'ok' }))
// Real ntfy servers expose this as their standard health/connectivity
// check, and some ntfy-compatible clients probe it before publishing —
// aliased here for that compatibility, not used internally.
app.get('/v1/health', async () => ({ status: 'ok' }))
await registerNotificationIngestRoutes(app)
await registerWebhookRoutes(app)
// Registered last: POST /:topic is a single-segment catch-all, so the
// more specific literal routes above must exist first for readability
// (Fastify's router already prefers literal matches regardless of
// registration order, but this keeps the file honest about intent).
await registerNtfyCompatRoutes(app)
return app
}
+124
View File
@@ -0,0 +1,124 @@
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import fastifyHelmet from '@fastify/helmet'
import fastifyStatic from '@fastify/static'
import fastifySwagger from '@fastify/swagger'
import fastifySwaggerUi from '@fastify/swagger-ui'
import Fastify, { type FastifyInstance } from 'fastify'
import { config } from '../config.js'
import { checkDbConnection } from '../db/client.js'
import { loggerOptions } from '../logger.js'
import { registerAdminRoutes } from '../modules/admin/routes.js'
import { registerAuthRoutes } from '../modules/auth/routes.js'
import { registerCsrfGuard } from '../modules/auth/csrf.js'
import { registerSession } from '../modules/auth/session.js'
import { registerNotificationReadRoutes } from '../modules/notifications/routes.js'
import { registerStreamRoutes } from '../modules/notifications/stream-routes.js'
import { registerPushRoutes } from '../modules/push/routes.js'
import { registerTokenRoutes } from '../modules/tokens/routes.js'
import { registerTopicRoutes } from '../modules/topics/routes.js'
// In dev, apps/web/dist is written by `vite build --watch` and may not
// exist yet on a cold container start; @fastify/static shouldn't be handed
// a missing root directory. Seed a placeholder so startup never races the
// first build.
function ensureStaticDirReady(dir: string) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const indexPath = path.join(dir, 'index.html')
if (!existsSync(indexPath)) {
writeFileSync(
indexPath,
'<!doctype html><title>Notizarr</title><body>Building web assets, refresh shortly…</body>',
)
}
}
// Listener A — public web/PWA + session-authenticated API.
// See docs/phase1/01-architecture.md. Push (Phase 7) and the rest of the
// admin API (Phase 8) land later; auth, topics, notifications, and API
// tokens are wired up as of Phase 4.
export async function buildWebApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: loggerOptions, bodyLimit: 1024 * 1024 })
// CSP/HSTS/frame-ancestors per docs/phase1/00-README.md's security
// requirements. crossOriginEmbedderPolicy is off because notification
// image_url values are arbitrary third-party https URLs the browser
// loads directly (docs/phase1/02-threat-model.md's SSRF mitigation) —
// COEP would require CORP headers from origins we don't control. HSTS is
// only sent in production so a plain-HTTP dev/LAN setup never gets stuck
// with a stale HSTS pin from testing.
await app.register(fastifyHelmet, {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'https:', 'data:'],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
},
},
crossOriginEmbedderPolicy: false,
hsts: config.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false,
})
app.get('/health', async () => ({ status: 'ok' }))
app.get('/ready', async (_req, reply) => {
const dbOk = await checkDbConnection()
if (!dbOk) {
reply.code(503)
return { status: 'not_ready', db: false }
}
return { status: 'ready', db: true }
})
// Needs a DB round-trip to load/generate the session secret, so this is
// awaited before anything that depends on req.session.
await registerSession(app)
registerCsrfGuard(app)
if (config.docsEnabled) {
// Registered before the routes below so @fastify/swagger's `onRoute`
// hook picks up their schemas.
await app.register(fastifySwagger, {
openapi: {
info: { title: 'Notizarr API', version: '0.1.0' },
},
})
await app.register(fastifySwaggerUi, { routePrefix: '/docs' })
}
await registerAuthRoutes(app)
await registerTokenRoutes(app)
await registerTopicRoutes(app)
await registerNotificationReadRoutes(app)
await registerStreamRoutes(app)
await registerPushRoutes(app)
await registerAdminRoutes(app)
ensureStaticDirReady(config.staticWebDir)
await app.register(fastifyStatic, {
root: config.staticWebDir,
prefix: '/',
decorateReply: true,
})
app.setNotFoundHandler((req, reply) => {
if (req.raw.method !== 'GET' || req.url.startsWith('/api/')) {
reply.code(404).send({ error: 'not_found' })
return
}
reply.sendFile('index.html', config.staticWebDir)
})
return app
}
+29
View File
@@ -0,0 +1,29 @@
import { config } from './config.js'
// Redaction covers everything called out in docs/phase1/02-threat-model.md
// under "Secret leakage via logs" — extend this list whenever a module adds
// a new field that can carry a credential.
export const loggerOptions = {
level: config.logLevel,
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]',
'*.password',
'*.token',
'*.tokenHash',
'*.clientSecret',
'*.vapidPrivateKey',
],
remove: true,
},
...(config.isProduction
? {}
: {
transport: {
target: 'pino-pretty',
options: { colorize: true, translateTime: 'HH:MM:ss', ignore: 'pid,hostname' },
},
}),
}
+8
View File
@@ -0,0 +1,8 @@
import { desc } from 'drizzle-orm'
import { db } from '../../db/client.js'
import { auditLog } from '../../db/schema.js'
export async function listAuditLog(limit = 100) {
const boundedLimit = Math.min(Math.max(limit, 1), 500)
return db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(boundedLimit)
}
+119
View File
@@ -0,0 +1,119 @@
import { spawn } from 'node:child_process'
import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
import path from 'node:path'
import { createGzip } from 'node:zlib'
import { config } from '../../config.js'
// Matches scripts/backup.sh's naming so both paths (in-app and the
// standalone script) coexist cleanly in the same directory.
const BACKUP_FILENAME_PATTERN = /^notizarr-db-\d{8}-\d{6}\.sql\.gz$/
const MAX_BACKUPS_TO_KEEP = 14
function backupsDir(): string {
if (!existsSync(config.backupsDir)) {
mkdirSync(config.backupsDir, { recursive: true })
}
return config.backupsDir
}
function timestamp(now = new Date()): string {
const pad = (n: number) => String(n).padStart(2, '0')
return (
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-` +
`${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
)
}
function pruneOldBackups(dir: string): void {
const files = readdirSync(dir)
.filter((f) => BACKUP_FILENAME_PATTERN.test(f))
.sort()
.reverse()
for (const stale of files.slice(MAX_BACKUPS_TO_KEEP)) {
unlinkSync(path.join(dir, stale))
}
}
export interface BackupResult {
filename: string
sizeBytes: number
}
// Shells out to pg_dump rather than using the `pg` client library directly
// — a proper dump (schema + data, in pg_dump's own restorable format) isn't
// something worth reimplementing. `--dbname` accepts a full connection URI,
// so no credential parsing/reassembly here. spawn(), not exec(): arguments
// are passed as an array, never interpolated into a shell string.
export async function runBackup(): Promise<BackupResult> {
const dir = backupsDir()
const filename = `notizarr-db-${timestamp()}.sql.gz`
const fullPath = path.join(dir, filename)
await new Promise<void>((resolve, reject) => {
const dump = spawn('pg_dump', ['--dbname', config.databaseUrl, '--format=plain'])
let stderr = ''
dump.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString()
})
dump.on('error', reject)
const out = createWriteStream(fullPath)
dump.stdout.pipe(createGzip()).pipe(out)
out.on('error', reject)
dump.on('close', (code) => {
if (code === 0) resolve()
else reject(new Error(`pg_dump exited with code ${code}: ${stderr.slice(0, 2000)}`))
})
})
pruneOldBackups(dir)
return { filename, sizeBytes: statSync(fullPath).size }
}
export interface BackupFile {
filename: string
sizeBytes: number
createdAt: string
}
export function listBackups(): BackupFile[] {
const dir = backupsDir()
return readdirSync(dir)
.filter((f) => BACKUP_FILENAME_PATTERN.test(f))
.map((filename) => {
const stat = statSync(path.join(dir, filename))
return { filename, sizeBytes: stat.size, createdAt: stat.mtime.toISOString() }
})
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
}
// Filename is validated against the fixed pattern above before it ever
// touches the filesystem — the only defense against path traversal that
// actually matters here, since the value comes straight from a URL param.
export function resolveBackupPath(filename: string): string | null {
if (!BACKUP_FILENAME_PATTERN.test(filename)) return null
const fullPath = path.join(backupsDir(), filename)
return existsSync(fullPath) ? fullPath : null
}
const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000 // 1 day
// Daily, plus a catch-up run on boot if the newest backup is already
// stale (or there isn't one) — covers a fresh install and a boot after
// downtime longer than a day, without also firing on every `tsx watch`
// restart during dev, which happens far more often than once a day.
export function startBackupJob(onError: (err: unknown) => void = console.error): NodeJS.Timeout {
const run = () => {
runBackup().catch(onError)
}
const [latest] = listBackups()
const isStale = !latest || Date.now() - new Date(latest.createdAt).getTime() > BACKUP_INTERVAL_MS
if (isStale) run()
const timer = setInterval(run, BACKUP_INTERVAL_MS)
timer.unref()
return timer
}
+20
View File
@@ -0,0 +1,20 @@
import { desc, eq } from 'drizzle-orm'
import { db } from '../../db/client.js'
import { pushSubscriptions, users } from '../../db/schema.js'
export async function listAllDevices() {
return db
.select({
id: pushSubscriptions.id,
deviceLabel: pushSubscriptions.deviceLabel,
userAgent: pushSubscriptions.userAgent,
createdAt: pushSubscriptions.createdAt,
lastSeenAt: pushSubscriptions.lastSeenAt,
disabledAt: pushSubscriptions.disabledAt,
userEmail: users.email,
userDisplayName: users.displayName,
})
.from(pushSubscriptions)
.innerJoin(users, eq(users.id, pushSubscriptions.userId))
.orderBy(desc(pushSubscriptions.createdAt))
}
+293
View File
@@ -0,0 +1,293 @@
import { createReadStream } from 'node:fs'
import type { FastifyInstance } from 'fastify'
import { config } from '../../config.js'
import { requireAdmin } from '../auth/guards.js'
import { invalidateOidcClient, resolveOidcConfig } from '../auth/oidc.js'
import { getStoredOidcSettings, setStoredOidcSettings } from '../auth/oidc-settings.js'
import { writeAuditLog } from '../audit/log.js'
import { listAuditLog } from './audit.js'
import { listBackups, resolveBackupPath, runBackup } from './backup.js'
import { listAllDevices } from './devices.js'
import { listSourceVolumes } from './sources.js'
import { getDashboardStats, getTopicStats } from './stats.js'
import { DEFAULT_QUIET_HOURS, getStoredSystemSettings, setStoredSystemSettings } from './system-settings.js'
import { listAllUsers, setUserRole } from './users.js'
export async function registerAdminRoutes(app: FastifyInstance) {
app.get('/api/v1/admin/stats/dashboard', { preHandler: requireAdmin }, async () => {
return getDashboardStats()
})
app.get('/api/v1/admin/sources', { preHandler: requireAdmin }, async () => {
const sources = await listSourceVolumes()
return { sources }
})
app.get(
'/api/v1/admin/stats/topics/:slug',
{
preHandler: requireAdmin,
schema: {
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
},
},
async (req, reply) => {
const { slug } = req.params as { slug: string }
const stats = await getTopicStats(slug)
if (!stats) {
reply.code(404)
return { error: 'not_found' }
}
return stats
},
)
app.get('/api/v1/admin/audit-log', { preHandler: requireAdmin }, async (req) => {
const { limit } = req.query as { limit?: number }
const entries = await listAuditLog(limit)
return { entries }
})
app.get('/api/v1/admin/users', { preHandler: requireAdmin }, async () => {
const allUsers = await listAllUsers()
return {
users: allUsers.map((u) => ({
id: u.id,
email: u.email,
displayName: u.displayName,
role: u.role,
createdAt: u.createdAt,
lastLoginAt: u.lastLoginAt,
})),
}
})
app.patch(
'/api/v1/admin/users/:id',
{
preHandler: requireAdmin,
schema: {
params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
body: {
type: 'object',
required: ['role'],
properties: { role: { type: 'string', enum: ['admin', 'user'] } },
},
},
},
async (req, reply) => {
const { id } = req.params as { id: string }
const { role } = req.body as { role: 'admin' | 'user' }
// A lone admin locking themselves out would need direct DB access to
// recover — cheap enough to guard against here.
if (id === req.currentUser!.id && role !== 'admin') {
reply.code(400)
return { error: 'invalid_operation', message: "you can't remove your own admin role" }
}
const updated = await setUserRole(id, role)
if (!updated) {
reply.code(404)
return { error: 'not_found' }
}
await writeAuditLog({
actorUserId: req.currentUser!.id,
action: 'user.role_change',
targetType: 'user',
targetId: updated.id,
metadata: { email: updated.email, role: updated.role },
ipAddress: req.ip,
})
return { user: { id: updated.id, email: updated.email, role: updated.role } }
},
)
app.get('/api/v1/admin/devices', { preHandler: requireAdmin }, async () => {
const devices = await listAllDevices()
return { devices }
})
app.get('/api/v1/admin/settings/oidc', { preHandler: requireAdmin }, async () => {
const stored = await getStoredOidcSettings()
const effective = await resolveOidcConfig()
return {
authMode: config.authMode,
// clientSecret is never sent back to the browser — only whether one is
// currently set, so the form can show "leave blank to keep existing".
issuerUrl: stored?.issuerUrl ?? '',
clientId: stored?.clientId ?? '',
clientSecretSet: Boolean(effective.clientSecret),
redirectUrl: stored?.redirectUrl ?? '',
scopes: stored?.scopes ?? '',
effectiveRedirectUrl: effective.redirectUrl || `${config.publicBaseUrl}/api/v1/auth/oidc/callback`,
}
})
app.patch(
'/api/v1/admin/settings/oidc',
{
preHandler: requireAdmin,
schema: {
body: {
type: 'object',
required: ['issuerUrl', 'clientId', 'redirectUrl', 'scopes'],
properties: {
issuerUrl: { type: 'string' },
clientId: { type: 'string' },
// Omitted/empty means "keep the currently stored secret" —
// mirrors how other integrations in this app handle secrets that
// shouldn't round-trip back to the browser once set.
clientSecret: { type: 'string' },
redirectUrl: { type: 'string' },
scopes: { type: 'string' },
},
},
},
},
async (req) => {
const body = req.body as {
issuerUrl: string
clientId: string
clientSecret?: string
redirectUrl: string
scopes: string
}
const existing = await getStoredOidcSettings()
const clientSecret = body.clientSecret?.trim() ? body.clientSecret.trim() : (existing?.clientSecret ?? '')
await setStoredOidcSettings({
issuerUrl: body.issuerUrl.trim(),
clientId: body.clientId.trim(),
clientSecret,
redirectUrl: body.redirectUrl.trim(),
scopes: body.scopes.trim(),
})
invalidateOidcClient()
// Never logs the secret itself — just that the settings changed.
await writeAuditLog({
actorUserId: req.currentUser!.id,
action: 'settings.oidc_update',
targetType: 'system_settings',
targetId: 'oidc_config',
metadata: { issuerUrl: body.issuerUrl.trim(), clientId: body.clientId.trim() },
ipAddress: req.ip,
})
return { status: 'ok' }
},
)
app.get('/api/v1/admin/settings/system', { preHandler: requireAdmin }, async () => {
const stored = await getStoredSystemSettings()
return {
retentionDefaultDays: stored?.retentionDefaultDays ?? config.retentionDefaultDays,
vapidSubject: stored?.vapidSubject || config.vapidSubject,
quietHours: stored?.quietHours ?? DEFAULT_QUIET_HOURS,
}
})
app.patch(
'/api/v1/admin/settings/system',
{
preHandler: requireAdmin,
schema: {
body: {
type: 'object',
properties: {
retentionDefaultDays: { type: 'integer', minimum: 0 },
vapidSubject: { type: 'string', minLength: 1 },
quietHours: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
start: { type: 'string', pattern: '^\\d{2}:\\d{2}$' },
end: { type: 'string', pattern: '^\\d{2}:\\d{2}$' },
timezone: { type: 'string', minLength: 1 },
minPriority: { type: 'integer', minimum: 1, maximum: 5 },
},
},
},
},
},
},
async (req) => {
const body = req.body as {
retentionDefaultDays?: number
vapidSubject?: string
quietHours?: {
enabled: boolean
start: string
end: string
timezone: string
minPriority: number
}
}
const existing = await getStoredSystemSettings()
await setStoredSystemSettings({
retentionDefaultDays: body.retentionDefaultDays ?? existing?.retentionDefaultDays,
vapidSubject: body.vapidSubject?.trim() || existing?.vapidSubject,
quietHours: body.quietHours ?? existing?.quietHours,
})
await writeAuditLog({
actorUserId: req.currentUser!.id,
action: 'settings.system_update',
targetType: 'system_settings',
targetId: 'system_config',
metadata: body as Record<string, unknown>,
ipAddress: req.ip,
})
return { status: 'ok' }
},
)
app.get('/api/v1/admin/backups', { preHandler: requireAdmin }, async () => {
return { backups: listBackups() }
})
app.post('/api/v1/admin/backups', { preHandler: requireAdmin }, async (req, reply) => {
try {
const result = await runBackup()
await writeAuditLog({
actorUserId: req.currentUser!.id,
action: 'backup.create',
targetType: 'backup',
targetId: result.filename,
metadata: { sizeBytes: result.sizeBytes },
ipAddress: req.ip,
})
reply.code(201)
return { backup: result }
} catch (err) {
req.log.error(err, 'manual backup failed')
reply.code(500)
return { error: 'backup_failed', message: err instanceof Error ? err.message : String(err) }
}
})
app.get(
'/api/v1/admin/backups/:filename/download',
{
preHandler: requireAdmin,
schema: {
params: { type: 'object', properties: { filename: { type: 'string' } }, required: ['filename'] },
},
},
async (req, reply) => {
const { filename } = req.params as { filename: string }
const fullPath = resolveBackupPath(filename)
if (!fullPath) {
reply.code(404)
return { error: 'not_found' }
}
reply.header('Content-Disposition', `attachment; filename="${filename}"`)
reply.type('application/gzip')
return createReadStream(fullPath)
},
)
}
+21
View File
@@ -0,0 +1,21 @@
import { gte, sql } from 'drizzle-orm'
import { db } from '../../db/client.js'
import { notifications } from '../../db/schema.js'
const NOISY_THRESHOLD_PER_DAY = 200
export async function listSourceVolumes() {
const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000)
const rows = await db
.select({
source: notifications.source,
last24h: sql<number>`count(*)::int`,
})
.from(notifications)
.where(gte(notifications.createdAt, last24h))
.groupBy(notifications.source)
.orderBy(sql`count(*) desc`)
return rows.map((row) => ({ ...row, noisy: row.last24h > NOISY_THRESHOLD_PER_DAY }))
}
+138
View File
@@ -0,0 +1,138 @@
import { desc, eq, gte, sql } from 'drizzle-orm'
import { db } from '../../db/client.js'
import {
auditLog,
notifications,
pushDeliveryAttempts,
pushSubscriptions,
topics,
users,
} from '../../db/schema.js'
import { getTopicBySlug } from '../topics/repository.js'
const FAILURE_STATUSES = sql`('failed', 'expired', 'invalid_subscription')`
export async function getDashboardStats() {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const [[notificationCounts], [pushCounts], [userCount], [topicCount], [deviceCount]] =
await Promise.all([
db
.select({
today: sql<number>`count(*) filter (where ${notifications.createdAt} >= ${startOfToday})::int`,
last24h: sql<number>`count(*) filter (where ${notifications.createdAt} >= ${last24h})::int`,
})
.from(notifications),
db
.select({
attempts: sql<number>`count(*) filter (where ${pushDeliveryAttempts.attemptedAt} >= ${last24h})::int`,
failures: sql<number>`count(*) filter (where ${pushDeliveryAttempts.attemptedAt} >= ${last24h} and ${pushDeliveryAttempts.status} in ${FAILURE_STATUSES})::int`,
})
.from(pushDeliveryAttempts),
db.select({ c: sql<number>`count(*)::int` }).from(users),
db.select({ c: sql<number>`count(*)::int` }).from(topics),
db
.select({ c: sql<number>`count(*)::int` })
.from(pushSubscriptions)
.where(sql`${pushSubscriptions.disabledAt} is null`),
])
const topTopics = await db
.select({
slug: topics.slug,
displayName: topics.displayName,
total: sql<number>`count(${notifications.id})::int`,
})
.from(notifications)
.innerJoin(topics, eq(topics.id, notifications.topicId))
.groupBy(topics.id, topics.slug, topics.displayName)
.orderBy(desc(sql`count(${notifications.id})`))
.limit(5)
const noisiestSources = await db
.select({
source: notifications.source,
total: sql<number>`count(*)::int`,
})
.from(notifications)
.where(gte(notifications.createdAt, last24h))
.groupBy(notifications.source)
.orderBy(desc(sql`count(*)`))
.limit(5)
const recentAuditEvents = await db
.select()
.from(auditLog)
.orderBy(desc(auditLog.createdAt))
.limit(10)
// A complete 14-day series including zero-count days (so a bar chart
// never has silent gaps) needs generate_series, which isn't expressible
// through the query builder — raw SQL via db.execute() is the
// straightforward option here.
const dailyResult = await db.execute<{ day: string; count: number }>(sql`
SELECT to_char(d::date, 'YYYY-MM-DD') AS day, count(n.id)::int AS count
FROM generate_series((current_date - interval '13 days')::date, current_date::date, interval '1 day') AS d
LEFT JOIN ${notifications} AS n ON date_trunc('day', n.created_at) = d
GROUP BY d
ORDER BY d
`)
const notificationsByDay = dailyResult.rows
return {
notificationsToday: notificationCounts?.today ?? 0,
notificationsLast24h: notificationCounts?.last24h ?? 0,
pushAttemptsLast24h: pushCounts?.attempts ?? 0,
pushFailuresLast24h: pushCounts?.failures ?? 0,
activeDevices: deviceCount?.c ?? 0,
userCount: userCount?.c ?? 0,
topicCount: topicCount?.c ?? 0,
topTopics,
notificationsByDay,
// "unusually noisy" is a fixed, simple threshold rather than
// statistical anomaly detection — matches the "keep it simple"
// principle in docs/phase1/00-README.md. Tune via NOISY_SOURCE_THRESHOLD
// if it doesn't fit real usage.
noisiestSources: noisiestSources.map((s) => ({ ...s, noisy: s.total > 200 })),
recentAuditEvents,
}
}
export interface TopicStats {
dailyCounts: { day: string; count: number }[]
topMessages: { message: string; total: number }[]
}
// Grouping by the exact message text is a simple heuristic that happens to
// work well for templated senders (Home Assistant etc.) where the same
// event always produces byte-identical text — e.g. "Ytterdörren är öppen,
// glöm inte stänga 🚪" naturally becomes its own bucket, separate from
// "...är stängd". One-off messages (AI-generated descriptions, etc.) just
// end up as singleton buckets, which the `limit` below keeps out of the way.
export async function getTopicStats(slug: string): Promise<TopicStats | null> {
const topic = await getTopicBySlug(slug)
if (!topic) return null
const dailyResult = await db.execute<{ day: string; count: number }>(sql`
SELECT to_char(d::date, 'YYYY-MM-DD') AS day, count(n.id)::int AS count
FROM generate_series((current_date - interval '13 days')::date, current_date::date, interval '1 day') AS d
LEFT JOIN ${notifications} AS n ON date_trunc('day', n.created_at) = d AND n.topic_id = ${topic.id}
GROUP BY d
ORDER BY d
`)
const topMessages = await db
.select({
message: notifications.message,
total: sql<number>`count(*)::int`,
})
.from(notifications)
.where(eq(notifications.topicId, topic.id))
.groupBy(notifications.message)
.orderBy(desc(sql`count(*)`))
.limit(15)
return { dailyCounts: dailyResult.rows, topMessages }
}
@@ -0,0 +1,52 @@
import { config } from '../../config.js'
import { getSetting, setSetting } from '../../db/settings.js'
const SYSTEM_SETTINGS_KEY = 'system_config'
export interface QuietHoursSettings {
enabled: boolean
start: string // "HH:MM", local to `timezone`
end: string // "HH:MM" — end < start means the window wraps past midnight
timezone: string // IANA name, e.g. "Europe/Stockholm"
minPriority: number // during the window, only notifications at/above this priority still push
}
export interface StoredSystemSettings {
retentionDefaultDays?: number
vapidSubject?: string
quietHours?: QuietHoursSettings
}
export const DEFAULT_QUIET_HOURS: QuietHoursSettings = {
enabled: false,
start: '23:00',
end: '07:00',
timezone: 'UTC',
minPriority: 4,
}
// Admin-UI-editable overrides for RETENTION_DEFAULT_DAYS / VAPID_SUBJECT —
// same DB-first, env-fallback pattern as modules/auth/oidc-settings.ts, so
// these can be tuned without an env file edit + restart.
export async function getStoredSystemSettings(): Promise<StoredSystemSettings | null> {
return getSetting<StoredSystemSettings>(SYSTEM_SETTINGS_KEY)
}
export async function setStoredSystemSettings(settings: StoredSystemSettings): Promise<void> {
await setSetting(SYSTEM_SETTINGS_KEY, settings)
}
export async function resolveRetentionDefaultDays(): Promise<number> {
const stored = await getStoredSystemSettings()
return stored?.retentionDefaultDays ?? config.retentionDefaultDays
}
export async function resolveVapidSubject(): Promise<string> {
const stored = await getStoredSystemSettings()
return stored?.vapidSubject || config.vapidSubject
}
export async function resolveQuietHours(): Promise<QuietHoursSettings> {
const stored = await getStoredSystemSettings()
return stored?.quietHours ?? DEFAULT_QUIET_HOURS
}
+12
View File
@@ -0,0 +1,12 @@
import { desc, eq } from 'drizzle-orm'
import { db } from '../../db/client.js'
import { users } from '../../db/schema.js'
export async function listAllUsers() {
return db.select().from(users).orderBy(desc(users.createdAt))
}
export async function setUserRole(id: string, role: 'admin' | 'user') {
const [updated] = await db.update(users).set({ role }).where(eq(users.id, id)).returning()
return updated ?? null
}
+28
View File
@@ -0,0 +1,28 @@
import { db } from '../../db/client.js'
import { auditLog } from '../../db/schema.js'
export interface WriteAuditLogInput {
actorUserId?: string | null
actorTokenId?: string | null
action: string
targetType?: string | null
targetId?: string | null
metadata?: Record<string, unknown> | null
ipAddress?: string | null
}
// Deliberately not used for every request — see docs/phase1/02-threat-model.md
// ("Retention / data minimization"): the audit log stays meaningful by only
// recording security-relevant events (auth, token/topic management,
// rate-limit rejections), not routine reads.
export async function writeAuditLog(input: WriteAuditLogInput): Promise<void> {
await db.insert(auditLog).values({
actorUserId: input.actorUserId ?? null,
actorTokenId: input.actorTokenId ?? null,
action: input.action,
targetType: input.targetType ?? null,
targetId: input.targetId ?? null,
metadata: input.metadata ?? null,
ipAddress: input.ipAddress ?? null,
})
}
@@ -0,0 +1,66 @@
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { pool } from '../../db/client.js'
import { buildWebApp } from '../../listeners/web.js'
// Integration test — boots the real web app (needs a migrated Postgres for
// session-secret bootstrap). Run with:
// docker compose -f docker/docker-compose.yml exec notizarr \
// pnpm --filter @notizarr/server test
describe('CSRF guard', () => {
let app: FastifyInstance
beforeAll(async () => {
app = await buildWebApp()
await app.ready()
})
afterAll(async () => {
await app.close()
await pool.end()
})
it('rejects a mutating request without the CSRF header', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/admin/topics',
payload: { slug: 'csrf-test', displayName: 'CSRF Test' },
})
expect(res.statusCode).toBe(403)
expect(res.json().error).toBe('csrf_check_failed')
})
it('allows a GET request through without the CSRF header', async () => {
const res = await app.inject({ method: 'GET', url: '/health' })
expect(res.statusCode).toBe(200)
})
it('rejects a cross-origin mutating request even with the CSRF header', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/admin/topics',
headers: { 'x-requested-with': 'notizarr', origin: 'http://evil.example', host: 'notizarr.local' },
payload: { slug: 'csrf-test-2', displayName: 'CSRF Test 2' },
})
expect(res.statusCode).toBe(403)
expect(res.json().message).toBe('origin mismatch')
})
it('allows a same-origin mutating request regardless of which hostname is used', async () => {
// Regression test: the check must compare Origin against the request's
// own Host header, not a fixed configured URL — this server is
// legitimately reachable at more than one hostname (LAN IP, public
// domain via Pangolin).
const res = await app.inject({
method: 'POST',
url: '/api/v1/admin/topics',
headers: {
'x-requested-with': 'notizarr',
origin: 'http://192.168.1.50:18080',
host: '192.168.1.50:18080',
},
payload: { slug: 'csrf-test-3', displayName: 'CSRF Test 3' },
})
expect(res.statusCode).not.toBe(403)
})
})
+50
View File
@@ -0,0 +1,50 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
const REQUIRED_HEADER = 'x-requested-with'
const REQUIRED_HEADER_VALUE = 'notizarr'
// Origin-check + custom-header CSRF defense for cookie-authenticated
// mutations on the web listener — see docs/phase1/02-threat-model.md,
// "Session hijacking / CSRF". A cross-site <form> POST can't set a custom
// header, and a cross-site fetch/XHR that tries to would fail CORS (no
// Access-Control-Allow-Origin is ever set), so this blocks CSRF without a
// token handshake. Every same-origin fetch the SPA makes for a mutating
// call must send X-Requested-With: notizarr.
//
// The Origin check compares against the request's own Host header, not a
// configured PUBLIC_BASE_URL — this server is legitimately reachable at
// more than one hostname (a LAN IP during setup/testing, the public domain
// through Pangolin in production), and a same-origin check should hold for
// all of them rather than only whichever one happened to be configured.
export function registerCsrfGuard(app: FastifyInstance): void {
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
if (!req.url.startsWith('/api/')) return
if (!MUTATING_METHODS.has(req.method)) return
const header = req.headers[REQUIRED_HEADER]
if (header !== REQUIRED_HEADER_VALUE) {
reply.code(403)
return reply.send({
error: 'csrf_check_failed',
message: `missing or invalid ${REQUIRED_HEADER} header`,
})
}
const origin = req.headers.origin
if (origin) {
let originHost: string
try {
originHost = new URL(origin).host
} catch {
reply.code(403)
return reply.send({ error: 'csrf_check_failed', message: 'invalid origin header' })
}
if (originHost !== req.headers.host) {
reply.code(403)
return reply.send({ error: 'csrf_check_failed', message: 'origin mismatch' })
}
}
})
}
+13
View File
@@ -0,0 +1,13 @@
import { findOrCreateUser } from './users.js'
// AUTH_MODE=disabled: every request is treated as one fixed local admin.
// Refused outright in production — see the startup check in src/index.ts —
// so this can never be the auth story for anything reachable off localhost.
export async function resolveDisabledModeUser() {
return findOrCreateUser({
authIssuer: null,
authSubject: 'local-disabled-mode',
email: 'admin@localhost',
displayName: 'Local Admin',
})
}
+43
View File
@@ -0,0 +1,43 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import { config } from '../../config.js'
import type { User } from '../../db/schema.js'
import { resolveDisabledModeUser } from './disabled.js'
import { resolveProxyUser } from './proxy.js'
import { getSessionUser } from './session.js'
declare module 'fastify' {
interface FastifyRequest {
currentUser?: User
}
}
export async function resolveCurrentUser(req: FastifyRequest): Promise<User | null> {
switch (config.authMode) {
case 'disabled':
return resolveDisabledModeUser()
case 'proxy':
return resolveProxyUser(req)
case 'oidc':
return getSessionUser(req)
default:
return null
}
}
export async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
const user = await resolveCurrentUser(req)
if (!user) {
reply.code(401)
return reply.send({ error: 'unauthorized' })
}
req.currentUser = user
}
export async function requireAdmin(req: FastifyRequest, reply: FastifyReply) {
await requireAuth(req, reply)
if (reply.sent) return
if (req.currentUser?.role !== 'admin') {
reply.code(403)
return reply.send({ error: 'forbidden', message: 'admin role required' })
}
}
@@ -0,0 +1,24 @@
import { getSetting, setSetting } from '../../db/settings.js'
const OIDC_SETTINGS_KEY = 'oidc_config'
export interface StoredOidcSettings {
issuerUrl: string
clientId: string
clientSecret: string
redirectUrl: string
scopes: string
}
// Admin-UI-editable override for the OIDC_ISSUER_URL/CLIENT_ID/CLIENT_SECRET/
// REDIRECT_URL/SCOPES env vars — stored here rather than requiring a config
// file edit + restart every time a provider detail changes. AUTH_MODE
// itself stays an env var on purpose (it changes which routes/listeners
// exist at boot, so switching it always needs a restart regardless).
export async function getStoredOidcSettings(): Promise<StoredOidcSettings | null> {
return getSetting<StoredOidcSettings>(OIDC_SETTINGS_KEY)
}
export async function setStoredOidcSettings(settings: StoredOidcSettings): Promise<void> {
await setSetting(OIDC_SETTINGS_KEY, settings)
}
+68
View File
@@ -0,0 +1,68 @@
import { generators, Issuer, type Client } from 'openid-client'
import { config } from '../../config.js'
import { getStoredOidcSettings } from './oidc-settings.js'
// NOTE: this module has not been verified end-to-end against a real
// identity provider — it's implemented to the openid-client v5 API and the
// flow described in docs/phase1/02-threat-model.md (Authorization Code +
// PKCE, state/nonce/iss/aud/signature validated by client.callback()), but
// treat it as needing a first real test pass once a provider is configured.
let clientPromise: Promise<Client> | null = null
// Called after Admin > Auth saves new settings, so the next login attempt
// picks them up without needing a process restart.
export function invalidateOidcClient(): void {
clientPromise = null
}
export interface EffectiveOidcConfig {
issuerUrl: string
clientId: string
clientSecret: string
redirectUrl: string
scopes: string
}
// DB-stored settings (Admin > Auth) take precedence over env vars, so an
// admin can configure/change the OIDC provider without touching the
// deployment's .env. Falls back to env vars so a fresh install still works
// with just OIDC_ISSUER_URL etc. set.
export async function resolveOidcConfig(): Promise<EffectiveOidcConfig> {
const stored = await getStoredOidcSettings()
return {
issuerUrl: stored?.issuerUrl || config.oidc.issuerUrl,
clientId: stored?.clientId || config.oidc.clientId,
clientSecret: stored?.clientSecret || config.oidc.clientSecret,
redirectUrl: stored?.redirectUrl || config.oidc.redirectUrl,
scopes: stored?.scopes || config.oidc.scopes,
}
}
export async function getClient(): Promise<Client> {
if (!clientPromise) {
clientPromise = (async () => {
const cfg = await resolveOidcConfig()
if (!cfg.issuerUrl) {
throw new Error(
'OIDC issuer URL is not configured — set it in Admin > Auth, or OIDC_ISSUER_URL.',
)
}
const issuer = await Issuer.discover(cfg.issuerUrl)
return new issuer.Client({
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
redirect_uris: [cfg.redirectUrl],
response_types: ['code'],
})
})()
// A discovery/config error shouldn't get stuck cached — clear it so the
// next login attempt (or the next settings save) can retry cleanly.
clientPromise.catch(() => {
clientPromise = null
})
}
return clientPromise
}
export { generators }
+46
View File
@@ -0,0 +1,46 @@
import type { FastifyRequest } from 'fastify'
import { config } from '../../config.js'
import { isIpInAnyCidr } from '../../lib/cidr.js'
import { findOrCreateUser } from './users.js'
export interface ProxyIdentity {
email: string
displayName: string
}
// Only trusted when the request's direct TCP peer is inside
// TRUSTED_PROXY_CIDRS — deliberately the raw socket address, not
// X-Forwarded-For, so a client can't just set that header itself. See
// docs/phase1/02-threat-model.md, "Spoofed identity via proxy headers".
export function readTrustedProxyIdentity(req: FastifyRequest): ProxyIdentity | null {
if (!config.authProxy.emailHeader) return null
const peerIp = req.socket.remoteAddress
if (!peerIp || !isIpInAnyCidr(peerIp, config.trustedProxyCidrs)) {
return null
}
const email = req.headers[config.authProxy.emailHeader] as string | undefined
if (!email) return null
const userHeaderValue = config.authProxy.userHeader
? (req.headers[config.authProxy.userHeader] as string | undefined)
: undefined
return {
email,
displayName: userHeaderValue ?? email,
}
}
export async function resolveProxyUser(req: FastifyRequest) {
const identity = readTrustedProxyIdentity(req)
if (!identity) return null
return findOrCreateUser({
authIssuer: null,
authSubject: `proxy:${identity.email}`,
email: identity.email,
displayName: identity.displayName,
})
}
+96
View File
@@ -0,0 +1,96 @@
import type { FastifyInstance } from 'fastify'
import { config } from '../../config.js'
import type { User } from '../../db/schema.js'
import { writeAuditLog } from '../audit/log.js'
import { resolveCurrentUser } from './guards.js'
import { generators, getClient, resolveOidcConfig } from './oidc.js'
import { clearSession, setSessionUserId } from './session.js'
import { findOrCreateUser } from './users.js'
function toPublicUser(user: User) {
return { id: user.id, email: user.email, displayName: user.displayName, role: user.role }
}
export async function registerAuthRoutes(app: FastifyInstance) {
app.get('/api/v1/session', async (req) => {
const user = await resolveCurrentUser(req)
return { user: user ? toPublicUser(user) : null, authMode: config.authMode }
})
app.post('/api/v1/auth/logout', async (req) => {
const user = await resolveCurrentUser(req)
clearSession(req)
if (user) {
await writeAuditLog({ actorUserId: user.id, action: 'auth.logout', ipAddress: req.ip })
}
return { status: 'ok' }
})
if (config.authMode !== 'oidc') return
app.get('/api/v1/auth/oidc/login', async (req, reply) => {
const oidcClient = await getClient()
const oidcConfig = await resolveOidcConfig()
const codeVerifier = generators.codeVerifier()
const codeChallenge = generators.codeChallenge(codeVerifier)
const state = generators.state()
const nonce = generators.nonce()
req.session.set('oidcCodeVerifier', codeVerifier)
req.session.set('oidcState', state)
req.session.set('oidcNonce', nonce)
const authUrl = oidcClient.authorizationUrl({
scope: oidcConfig.scopes,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
nonce,
})
reply.redirect(authUrl)
})
app.get('/api/v1/auth/oidc/callback', async (req, reply) => {
const oidcClient = await getClient()
const oidcConfig = await resolveOidcConfig()
const params = oidcClient.callbackParams(req.raw)
const codeVerifier = req.session.get('oidcCodeVerifier') as string | undefined
const state = req.session.get('oidcState') as string | undefined
const nonce = req.session.get('oidcNonce') as string | undefined
if (!codeVerifier || !state || !nonce) {
reply.code(400)
return { error: 'invalid_oidc_state', message: 'login flow expired or was not started here' }
}
// client.callback() validates state, nonce, issuer, audience, and the
// ID token signature — the full checklist from
// docs/phase1/02-threat-model.md, "Forged/leaked OIDC tokens".
const tokenSet = await oidcClient.callback(oidcConfig.redirectUrl, params, {
code_verifier: codeVerifier,
state,
nonce,
})
const claims = tokenSet.claims()
const email = typeof claims.email === 'string' ? claims.email : undefined
const name = typeof claims.name === 'string' ? claims.name : undefined
const user = await findOrCreateUser({
authIssuer: claims.iss,
authSubject: claims.sub,
email: email ?? `${claims.sub}@${new URL(claims.iss).host}`,
displayName: name ?? email ?? claims.sub,
})
setSessionUserId(req, user.id)
req.session.set('oidcCodeVerifier', undefined)
req.session.set('oidcState', undefined)
req.session.set('oidcNonce', undefined)
await writeAuditLog({ actorUserId: user.id, action: 'auth.login', ipAddress: req.ip })
reply.redirect(config.publicBaseUrl)
})
}
+101
View File
@@ -0,0 +1,101 @@
import { randomBytes } from 'node:crypto'
import fastifyCookie from '@fastify/cookie'
import type { FastifyInstance, FastifyRequest } from 'fastify'
import { config } from '../../config.js'
import { getOrCreateSetting } from '../../db/settings.js'
import { decryptJson, encryptJson } from '../../lib/crypto-session.js'
import { getUserById } from './users.js'
const SESSION_SECRET_KEY = 'session_secret'
const SESSION_COOKIE_NAME = 'notizarr_session'
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 // 30 days
class Session {
private data: Record<string, unknown>
dirty = false
cleared = false
constructor(initial: Record<string, unknown>) {
this.data = initial
}
get<T = unknown>(key: string): T | undefined {
return this.data[key] as T | undefined
}
set(key: string, value: unknown): void {
if (value === undefined) {
delete this.data[key]
} else {
this.data[key] = value
}
this.dirty = true
}
delete(): void {
this.cleared = true
}
serialize(): Record<string, unknown> {
return this.data
}
}
declare module 'fastify' {
interface FastifyRequest {
session: Session
}
}
// The session secret is generated once on first boot and persisted in
// Postgres (same durability pattern as the VAPID keypair in Phase 7) so it
// survives container recreation — a stable secret is required for existing
// session cookies to keep validating across restarts.
export async function registerSession(app: FastifyInstance): Promise<void> {
const secretHex = await getOrCreateSetting(SESSION_SECRET_KEY, () =>
randomBytes(32).toString('hex'),
)
const key = Buffer.from(secretHex, 'hex')
await app.register(fastifyCookie)
app.addHook('onRequest', async (req: FastifyRequest) => {
const raw = req.cookies[SESSION_COOKIE_NAME]
const initial = raw ? (decryptJson<Record<string, unknown>>(key, raw) ?? {}) : {}
req.session = new Session(initial)
})
app.addHook('onSend', async (req: FastifyRequest, reply, payload) => {
if (!req.session) return payload
if (req.session.cleared) {
reply.clearCookie(SESSION_COOKIE_NAME, { path: '/' })
} else if (req.session.dirty) {
const value = encryptJson(key, req.session.serialize())
reply.setCookie(SESSION_COOKIE_NAME, value, {
path: '/',
httpOnly: true,
secure: config.isProduction,
sameSite: 'lax',
maxAge: SESSION_MAX_AGE_SECONDS,
})
}
return payload
})
}
export function setSessionUserId(req: FastifyRequest, userId: string): void {
req.session.set('userId', userId)
}
export function clearSession(req: FastifyRequest): void {
req.session.delete()
}
// AUTH_MODE=disabled and AUTH_MODE=proxy don't use this — see
// modules/auth/guards.ts, which only calls this for AUTH_MODE=oidc.
export async function getSessionUser(req: FastifyRequest) {
const userId = req.session.get<string>('userId')
if (!userId) return null
return getUserById(userId)
}
+66
View File
@@ -0,0 +1,66 @@
import { and, eq, isNull, sql } from 'drizzle-orm'
import { db } from '../../db/client.js'
import { users } from '../../db/schema.js'
export async function getUserById(id: string) {
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1)
return user ?? null
}
async function findByIssuerSubject(authIssuer: string | null, authSubject: string) {
const issuerClause = authIssuer === null ? isNull(users.authIssuer) : eq(users.authIssuer, authIssuer)
const [user] = await db
.select()
.from(users)
.where(and(issuerClause, eq(users.authSubject, authSubject)))
.limit(1)
return user ?? null
}
async function isFirstUser(): Promise<boolean> {
const [row] = await db.select({ count: sql<number>`count(*)::int` }).from(users)
return (row?.count ?? 0) === 0
}
export interface UpsertIdentityInput {
authIssuer: string | null
authSubject: string
email: string
displayName: string
avatarUrl?: string | null
}
// Shared by both OIDC callback and proxy-header auth: find the user for
// this identity, or create one. The very first user ever created is
// auto-promoted to admin (docs/phase1/02-threat-model.md, "bootstrap
// admin") so there's no chicken-and-egg problem on a fresh install.
export async function findOrCreateUser(input: UpsertIdentityInput) {
const existing = await findByIssuerSubject(input.authIssuer, input.authSubject)
if (existing) {
const [updated] = await db
.update(users)
.set({ lastLoginAt: new Date(), displayName: input.displayName, avatarUrl: input.avatarUrl ?? null })
.where(eq(users.id, existing.id))
.returning()
// Updating a row just found by its own id always returns exactly one row.
return updated!
}
const shouldBeAdmin = await isFirstUser()
const [created] = await db
.insert(users)
.values({
email: input.email,
displayName: input.displayName,
avatarUrl: input.avatarUrl ?? null,
authIssuer: input.authIssuer,
authSubject: input.authSubject,
role: shouldBeAdmin ? 'admin' : 'user',
lastLoginAt: new Date(),
})
.returning()
// A single-row insert always returns exactly one row.
return created!
}
@@ -0,0 +1,17 @@
import { EventEmitter } from 'node:events'
import type { Notification } from '../../db/schema.js'
// In-process pub/sub for the SSE stream (docs/phase1/01-architecture.md —
// single instance, no Redis). Every connected /api/v1/stream client adds a
// listener here; setMaxListeners(0) removes Node's default 10-listener
// warning cap, since "many browser tabs open" is the normal case.
export const notificationEvents = new EventEmitter()
notificationEvents.setMaxListeners(0)
export function emitNotificationCreated(notification: Notification): void {
notificationEvents.emit('notification.created', notification)
}
export function emitNotificationUpdated(notification: Notification): void {
notificationEvents.emit('notification.updated', notification)
}
@@ -0,0 +1,104 @@
import type { FastifyInstance } from 'fastify'
import { requireApiToken } from '../tokens/ingest-auth.js'
import { publishAndRespond } from './publish.js'
const publishBodySchema = {
type: 'object',
properties: {
title: { type: 'string', minLength: 1, maxLength: 500 },
message: { type: 'string', minLength: 1, maxLength: 10000 },
source: { type: 'string' },
url: { type: 'string' },
image_url: { type: 'string' },
deduplication_key: { type: 'string' },
// ntfy-style scale (1-5, or min/low/default/high/urgent) — priority 1
// ("min") is stored and shown in-app as usual but skipped during push
// fan-out, see modules/push/fanout.ts. Accepts a JSON number too since
// an unquoted Jinja template value in Home Assistant's payload renders
// as one; coerced to a string below either way, same as ntfy-routes.ts.
priority: { type: ['string', 'number'] },
},
} as const
interface PublishBody {
title: string
message: string
source?: string
url?: string
image_url?: string
deduplication_key?: string
priority?: string | number
}
// Ingestion listener (:8081) — bearer-token auth via requireApiToken.
// docs/phase1/04-api-design.md's two publish shapes:
// POST /api/v1/notifications (topic in the body)
// POST /api/v1/topics/:slug (topic in the path)
// ntfy-compat and webhook variants live in ntfy-routes.ts / webhook-routes.ts.
export async function registerNotificationIngestRoutes(app: FastifyInstance) {
app.post(
'/api/v1/notifications',
{
preHandler: requireApiToken,
schema: {
body: {
...publishBodySchema,
required: ['topic', 'title', 'message'],
properties: { topic: { type: 'string', minLength: 1 }, ...publishBodySchema.properties },
},
},
},
async (req, reply) => {
const body = req.body as PublishBody & { topic: string }
const tokenAllowedSource = req.apiToken?.allowedSource ?? null
if (tokenAllowedSource && body.source && body.source !== tokenAllowedSource) {
reply.code(403)
return { error: 'forbidden', message: 'source does not match token scope' }
}
await publishAndRespond(req, reply, {
topicSlug: body.topic,
source: body.source ?? tokenAllowedSource ?? 'unknown',
title: body.title,
message: body.message,
externalUrl: body.url ?? null,
imageUrl: body.image_url ?? null,
deduplicationKey: body.deduplication_key ?? null,
rawPriority: body.priority === undefined ? null : String(body.priority),
})
},
)
app.post(
'/api/v1/topics/:slug',
{
preHandler: requireApiToken,
schema: {
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
body: { ...publishBodySchema, required: ['title', 'message'] },
},
},
async (req, reply) => {
const { slug } = req.params as { slug: string }
const body = req.body as PublishBody
const tokenAllowedSource = req.apiToken?.allowedSource ?? null
if (tokenAllowedSource && body.source && body.source !== tokenAllowedSource) {
reply.code(403)
return { error: 'forbidden', message: 'source does not match token scope' }
}
await publishAndRespond(req, reply, {
topicSlug: slug,
source: body.source ?? tokenAllowedSource ?? 'unknown',
title: body.title,
message: body.message,
externalUrl: body.url ?? null,
imageUrl: body.image_url ?? null,
deduplicationKey: body.deduplication_key ?? null,
rawPriority: body.priority === undefined ? null : String(body.priority),
})
},
)
}
@@ -0,0 +1,128 @@
import { randomUUID } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
import { eq } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db, pool } from '../../db/client.js'
import { runMigrations } from '../../db/migrate.js'
import { apiTokens, notifications, topics, users } from '../../db/schema.js'
import { buildIngestApp } from '../../listeners/ingest.js'
import { createToken } from '../tokens/repository.js'
// Integration test — same requirements/run command as
// modules/tokens/repository.integration.test.ts. Verifies
// docs/migration-ntfy.md's claim that header-based, JSON-body-based, and
// query-param-based ntfy-style requests all produce equivalent stored
// notifications through the same POST /:topic endpoint.
describe('ntfy-compat endpoint', () => {
let app: FastifyInstance
const testSlug = `test-ntfy-${randomUUID()}`
const testAuthSubject = `test-ntfy-user-${randomUUID()}`
let userId: string
let topicId: string
let tokenId: string
let plaintext: string
// Within config.ts's default INGESTION_ALLOWED_CIDRS (10.0.0.0/8) so the
// source-address check in requireApiToken doesn't reject these.
const remoteAddress = '10.0.0.5'
beforeAll(async () => {
await runMigrations()
app = await buildIngestApp()
await app.ready()
const [user] = await db
.insert(users)
.values({
email: `${testAuthSubject}@example.test`,
displayName: 'Test User',
authSubject: testAuthSubject,
role: 'admin',
})
.returning()
userId = user!.id
const [topic] = await db
.insert(topics)
.values({ slug: testSlug, displayName: 'Test Topic', createdBy: userId })
.returning()
topicId = topic!.id
const created = await createToken({ name: 'ntfy-compat test token', createdBy: userId })
plaintext = created.plaintext
tokenId = created.token.id
})
afterAll(async () => {
await db.delete(notifications).where(eq(notifications.topicId, topicId))
await db.delete(apiTokens).where(eq(apiTokens.id, tokenId))
await db.delete(topics).where(eq(topics.id, topicId))
await db.delete(users).where(eq(users.id, userId))
await app.close()
await pool.end()
})
// The endpoint doesn't take a client-supplied dedup key, so tests
// identify their own row by a unique marker embedded in the message.
async function findByMessageMarker(marker: string) {
const rows = await db.select().from(notifications).where(eq(notifications.topicId, topicId))
return rows.find((r) => r.message.includes(marker)) ?? null
}
it('accepts a header-based publish (title/priority headers, plain-text body)', async () => {
const marker = randomUUID()
const res = await app.inject({
method: 'POST',
url: `/${testSlug}`,
remoteAddress,
headers: { authorization: `Bearer ${plaintext}`, title: 'Header title', priority: 'min' },
payload: `plain text body ${marker}`,
})
expect(res.statusCode).toBe(201)
const row = await findByMessageMarker(marker)
expect(row?.title).toBe('Header title')
expect(row?.rawPriority).toBe('min')
})
it('accepts a JSON-body publish with the same fields', async () => {
const marker = randomUUID()
const res = await app.inject({
method: 'POST',
url: `/${testSlug}`,
remoteAddress,
headers: { authorization: `Bearer ${plaintext}`, 'content-type': 'application/json' },
payload: JSON.stringify({ title: 'JSON title', message: `json body ${marker}`, priority: 'high' }),
})
expect(res.statusCode).toBe(201)
const row = await findByMessageMarker(marker)
expect(row?.title).toBe('JSON title')
expect(row?.rawPriority).toBe('high')
})
it('accepts a query-param-based publish', async () => {
const marker = randomUUID()
const res = await app.inject({
method: 'POST',
url: `/${testSlug}?title=${encodeURIComponent('Query title')}&message=${encodeURIComponent(`query body ${marker}`)}&priority=low`,
remoteAddress,
headers: { authorization: `Bearer ${plaintext}` },
})
expect(res.statusCode).toBe(201)
const row = await findByMessageMarker(marker)
expect(row?.title).toBe('Query title')
expect(row?.rawPriority).toBe('low')
})
it('rejects a request without a valid bearer token', async () => {
const res = await app.inject({
method: 'POST',
url: `/${testSlug}`,
remoteAddress,
payload: 'no auth',
})
expect(res.statusCode).toBe(401)
})
})
@@ -0,0 +1,78 @@
import type { FastifyInstance } from 'fastify'
import { requireApiToken } from '../tokens/ingest-auth.js'
import { publishAndRespond } from './publish.js'
function headerString(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value
}
// ntfy-compatibility endpoint — docs/phase1/04-api-design.md: eases
// migration from ntfy, not a full protocol clone (no priority-driven
// behavior, no attachment upload/proxying, no emoji tag mapping yet).
// Accepts, in order of precedence (highest first): headers, query
// parameters, JSON body, then a plain-text body as the message. Registered
// as POST /:topic, so it must not collide with any literal path — see the
// routing note in listeners/ingest.ts.
export async function registerNtfyCompatRoutes(app: FastifyInstance) {
app.post('/:topic', { preHandler: requireApiToken }, async (req, reply) => {
const { topic } = req.params as { topic: string }
const headers = req.headers
const query = req.query as Record<string, string | undefined>
let title: string | undefined
let message: string | undefined
let clickUrl: string | undefined
let attachUrl: string | undefined
let filename: string | undefined
let priority: string | undefined
if (req.body && typeof req.body === 'object') {
const body = req.body as Record<string, unknown>
title = typeof body.title === 'string' ? body.title : undefined
message = typeof body.message === 'string' ? body.message : undefined
clickUrl = typeof body.click === 'string' ? body.click : undefined
attachUrl = typeof body.attach === 'string' ? body.attach : undefined
filename = typeof body.filename === 'string' ? body.filename : undefined
priority = body.priority === undefined ? undefined : String(body.priority)
} else if (typeof req.body === 'string' && req.body.length > 0) {
message = req.body
}
// Query params (?title=...) are a convenience for clients that can't
// easily set custom headers — applied before headers so headers still
// win if both are somehow present.
title = query.title ?? title
message = query.message ?? message
clickUrl = query.click ?? clickUrl
attachUrl = query.attach ?? attachUrl
filename = query.filename ?? filename
priority = query.priority ?? priority
title = headerString(headers['title']) ?? title
clickUrl = headerString(headers['click']) ?? clickUrl
attachUrl = headerString(headers['attach']) ?? attachUrl
filename = headerString(headers['filename']) ?? filename
priority = headerString(headers['priority']) ?? priority
if (!message) {
reply.code(422)
return {
error: 'validation_error',
message: 'a message body (plain text, JSON "message" field, or empty) is required',
}
}
const tokenAllowedSource = req.apiToken?.allowedSource ?? null
await publishAndRespond(req, reply, {
topicSlug: topic,
source: tokenAllowedSource ?? 'ntfy-compat',
title: title ?? topic,
message,
externalUrl: clickUrl ?? null,
imageUrl: attachUrl ?? null,
attachmentFilename: filename ?? null,
rawPriority: priority ?? null,
})
})
}
@@ -0,0 +1,36 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import { fanOutPush } from '../push/fanout.js'
import { createNotification, type CreateNotificationInput } from './repository.js'
// Shared by every publish entry point (canonical, topic-path alias,
// ntfy-compat, webhook) so the topic-scope check and response shape stay
// identical no matter which endpoint a client used.
export async function publishAndRespond(
req: FastifyRequest,
reply: FastifyReply,
input: Omit<CreateNotificationInput, 'senderTokenId'>,
): Promise<void> {
if (req.apiTokenAllowedTopics && !req.apiTokenAllowedTopics.includes(input.topicSlug)) {
reply.code(403)
reply.send({ error: 'forbidden', message: `token is not scoped to topic: ${input.topicSlug}` })
return
}
const result = await createNotification({ ...input, senderTokenId: req.apiToken?.id ?? null })
if ('error' in result) {
reply.code(422)
reply.send({ error: result.error, message: `unknown topic: ${input.topicSlug}` })
return
}
reply.code(201)
reply.send({ id: result.notification.id, status: 'accepted' })
// Fire-and-forget: the LAN publisher's response must not wait on push
// delivery. Deduplicated repeats don't re-push, per
// docs/phase1/04-api-design.md's deduplication section.
if (!result.deduplicated) {
void fanOutPush(result.notification, input.topicSlug)
}
}
@@ -0,0 +1,75 @@
import { randomUUID } from 'node:crypto'
import { eq } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db, pool } from '../../db/client.js'
import { runMigrations } from '../../db/migrate.js'
import { topics } from '../../db/schema.js'
import { createNotification } from './repository.js'
// Integration tests — need a real, migrated Postgres. Run with:
// docker compose -f docker/docker-compose.yml exec notizarr \
// pnpm --filter @notizarr/server test
describe('notification deduplication', () => {
const testSlug = `test-topic-${randomUUID()}`
let topicId: string
beforeAll(async () => {
await runMigrations()
const [topic] = await db
.insert(topics)
.values({ slug: testSlug, displayName: 'Dedup Test Topic' })
.returning()
topicId = topic!.id
})
afterAll(async () => {
await db.delete(topics).where(eq(topics.id, topicId))
await pool.end()
})
it('increments occurrence_count instead of inserting a duplicate within the window', async () => {
const dedupKey = `dedup-${randomUUID()}`
const first = await createNotification({
topicSlug: testSlug,
source: 'test',
title: 'First',
message: 'First message',
deduplicationKey: dedupKey,
})
if ('error' in first) throw new Error('unexpected error on first insert')
expect(first.deduplicated).toBe(false)
expect(first.notification.occurrenceCount).toBe(1)
const second = await createNotification({
topicSlug: testSlug,
source: 'test',
title: 'Second (dup)',
message: 'Second message',
deduplicationKey: dedupKey,
})
if ('error' in second) throw new Error('unexpected error on second insert')
expect(second.deduplicated).toBe(true)
expect(second.notification.id).toBe(first.notification.id)
expect(second.notification.occurrenceCount).toBe(2)
})
it('does not deduplicate notifications with different keys', async () => {
const first = await createNotification({
topicSlug: testSlug,
source: 'test',
title: 'A',
message: 'A message',
deduplicationKey: `key-a-${randomUUID()}`,
})
const second = await createNotification({
topicSlug: testSlug,
source: 'test',
title: 'B',
message: 'B message',
deduplicationKey: `key-b-${randomUUID()}`,
})
if ('error' in first || 'error' in second) throw new Error('unexpected error')
expect(first.notification.id).not.toBe(second.notification.id)
})
})
@@ -0,0 +1,278 @@
import { and, desc, eq, gte, ilike, or, sql } from 'drizzle-orm'
import { db } from '../../db/client.js'
import { notifications, topics, userNotificationState } from '../../db/schema.js'
import { getTopicBySlug } from '../topics/repository.js'
import { emitNotificationCreated, emitNotificationUpdated } from './events.js'
const DEDUP_WINDOW_MS = 5 * 60 * 1000
export interface CreateNotificationInput {
topicSlug: string
source: string
title: string
message: string
externalUrl?: string | null
imageUrl?: string | null
attachmentFilename?: string | null
deduplicationKey?: string | null
rawPriority?: string | null
senderTokenId?: string | null
}
export type CreateNotificationResult =
| { notification: typeof notifications.$inferSelect; deduplicated: boolean }
| { error: 'topic_not_found' }
// Rate limiting happens in modules/tokens/ingest-auth.ts, before this is
// ever called — this function's only job is "store it, always" plus the
// deduplication window described in docs/phase1/04-api-design.md.
export async function createNotification(
input: CreateNotificationInput,
): Promise<CreateNotificationResult> {
const topic = await getTopicBySlug(input.topicSlug)
if (!topic) {
return { error: 'topic_not_found' }
}
if (input.deduplicationKey) {
const windowStart = new Date(Date.now() - DEDUP_WINDOW_MS)
const [existing] = await db
.select()
.from(notifications)
.where(
and(
eq(notifications.topicId, topic.id),
eq(notifications.deduplicationKey, input.deduplicationKey),
gte(notifications.createdAt, windowStart),
),
)
.orderBy(desc(notifications.createdAt))
.limit(1)
if (existing) {
const [updated] = await db
.update(notifications)
.set({ occurrenceCount: sql`${notifications.occurrenceCount} + 1`, updatedAt: new Date() })
.where(eq(notifications.id, existing.id))
.returning()
// Updating a row just found by its own id always returns exactly one row.
emitNotificationUpdated(updated!)
return { notification: updated!, deduplicated: true }
}
}
const [notification] = await db
.insert(notifications)
.values({
topicId: topic.id,
source: input.source,
title: input.title,
message: input.message,
externalUrl: input.externalUrl ?? null,
imageUrl: input.imageUrl ?? null,
attachmentFilename: input.attachmentFilename ?? null,
deduplicationKey: input.deduplicationKey ?? null,
rawPriority: input.rawPriority ?? null,
senderTokenId: input.senderTokenId ?? null,
})
.returning()
// A single-row insert always returns exactly one row.
emitNotificationCreated(notification!)
return { notification: notification!, deduplicated: false }
}
export interface NotificationWithState {
id: string
topicId: string
topicSlug: string
topicDisplayName: string
source: string
title: string
message: string
externalUrl: string | null
imageUrl: string | null
occurrenceCount: number
rawPriority: string | null
createdAt: Date
updatedAt: Date
read: boolean
starred: boolean
}
export interface ListNotificationsFilter {
topicSlug?: string
unreadOnly?: boolean
starredOnly?: boolean
q?: string
limit?: number
}
function toNotificationWithState(row: {
notification: typeof notifications.$inferSelect
state: typeof userNotificationState.$inferSelect | null
topic: { slug: string; displayName: string }
}): NotificationWithState {
return {
...row.notification,
topicSlug: row.topic.slug,
topicDisplayName: row.topic.displayName,
read: row.state?.read ?? false,
starred: row.state?.starred ?? false,
}
}
export async function listNotificationsForUser(
userId: string,
filter: ListNotificationsFilter = {},
): Promise<NotificationWithState[]> {
const boundedLimit = Math.min(Math.max(filter.limit ?? 50, 1), 200)
let topicId: string | undefined
if (filter.topicSlug) {
const topic = await getTopicBySlug(filter.topicSlug)
if (!topic) return []
topicId = topic.id
}
const conditions = [
topicId ? eq(notifications.topicId, topicId) : undefined,
filter.unreadOnly ? sql`coalesce(${userNotificationState.read}, false) = false` : undefined,
filter.starredOnly ? eq(userNotificationState.starred, true) : undefined,
filter.q
? or(ilike(notifications.title, `%${filter.q}%`), ilike(notifications.message, `%${filter.q}%`))
: undefined,
].filter((c): c is NonNullable<typeof c> => c !== undefined)
const rows = await db
.select({
notification: notifications,
state: userNotificationState,
topic: { slug: topics.slug, displayName: topics.displayName },
})
.from(notifications)
.innerJoin(topics, eq(topics.id, notifications.topicId))
.leftJoin(
userNotificationState,
and(
eq(userNotificationState.notificationId, notifications.id),
eq(userNotificationState.userId, userId),
),
)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(notifications.createdAt))
.limit(boundedLimit)
return rows.map(toNotificationWithState)
}
export async function getNotificationForUser(
userId: string,
id: string,
): Promise<NotificationWithState | null> {
const [row] = await db
.select({
notification: notifications,
state: userNotificationState,
topic: { slug: topics.slug, displayName: topics.displayName },
})
.from(notifications)
.innerJoin(topics, eq(topics.id, notifications.topicId))
.leftJoin(
userNotificationState,
and(
eq(userNotificationState.notificationId, notifications.id),
eq(userNotificationState.userId, userId),
),
)
.where(eq(notifications.id, id))
.limit(1)
return row ? toNotificationWithState(row) : null
}
export interface NotificationStateChanges {
read?: boolean
starred?: boolean
}
export async function setNotificationState(
userId: string,
notificationId: string,
changes: NotificationStateChanges,
) {
const [existing] = await db
.select()
.from(userNotificationState)
.where(
and(
eq(userNotificationState.userId, userId),
eq(userNotificationState.notificationId, notificationId),
),
)
.limit(1)
const patch = {
...(changes.read !== undefined ? { read: changes.read, readAt: changes.read ? new Date() : null } : {}),
...(changes.starred !== undefined
? { starred: changes.starred, starredAt: changes.starred ? new Date() : null }
: {}),
}
if (existing) {
const [updated] = await db
.update(userNotificationState)
.set(patch)
.where(eq(userNotificationState.id, existing.id))
.returning()
return updated
}
const [created] = await db
.insert(userNotificationState)
.values({
userId,
notificationId,
read: changes.read ?? false,
readAt: changes.read ? new Date() : null,
starred: changes.starred ?? false,
starredAt: changes.starred ? new Date() : null,
})
.returning()
return created
}
// Marks every notification the user hasn't read yet as read — either
// globally or scoped to one topic. Uses a single INSERT ... ON CONFLICT
// rather than one row at a time, since a first-ever "mark all read" can
// touch thousands of rows that have no user_notification_state row yet.
export async function markAllRead(userId: string, topicSlug?: string): Promise<number> {
let topicId: string | null = null
if (topicSlug) {
const topic = await getTopicBySlug(topicSlug)
if (!topic) return 0
topicId = topic.id
}
const result = await db.execute(sql`
INSERT INTO user_notification_state (user_id, notification_id, read, read_at)
SELECT ${userId}, n.id, true, now()
FROM ${notifications} AS n
${topicId ? sql`WHERE n.topic_id = ${topicId}` : sql``}
ON CONFLICT (user_id, notification_id)
DO UPDATE SET read = true, read_at = now()
WHERE ${userNotificationState}.read = false
`)
return result.rowCount ?? 0
}
// Permanent — cascades to user_notification_state and
// push_delivery_attempts rows for this notification via the FK
// onDelete: 'cascade' constraints in db/schema.ts. Admin-only (see
// routes.ts): notifications are shared across every user who can see the
// topic, so deleting one isn't a per-user action.
export async function deleteNotification(id: string) {
const [deleted] = await db.delete(notifications).where(eq(notifications.id, id)).returning()
return deleted ?? null
}
@@ -0,0 +1,96 @@
import { randomUUID } from 'node:crypto'
import { eq } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db, pool } from '../../db/client.js'
import { runMigrations } from '../../db/migrate.js'
import { notifications, topics } from '../../db/schema.js'
import { runRetentionCleanup } from './retention.js'
// Integration tests — need a real, migrated Postgres. Run with:
// docker compose -f docker/docker-compose.yml exec notizarr \
// pnpm --filter @notizarr/server test
describe('retention cleanup', () => {
const shortRetentionSlug = `retention-short-${randomUUID()}`
const foreverSlug = `retention-forever-${randomUUID()}`
let shortTopicId: string
let foreverTopicId: string
beforeAll(async () => {
await runMigrations()
const [shortTopic] = await db
.insert(topics)
.values({ slug: shortRetentionSlug, displayName: 'Short Retention', retentionDays: 1 })
.returning()
shortTopicId = shortTopic!.id
const [foreverTopic] = await db
.insert(topics)
.values({ slug: foreverSlug, displayName: 'Forever Retention', retentionDays: 0 })
.returning()
foreverTopicId = foreverTopic!.id
})
afterAll(async () => {
await db.delete(topics).where(eq(topics.id, shortTopicId))
await db.delete(topics).where(eq(topics.id, foreverTopicId))
await pool.end()
})
it('deletes notifications older than the topic retention window', async () => {
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000)
const [oldNotification] = await db
.insert(notifications)
.values({
topicId: shortTopicId,
source: 'test',
title: 'Old',
message: 'Should be cleaned up',
createdAt: twoDaysAgo,
})
.returning()
const [recentNotification] = await db
.insert(notifications)
.values({ topicId: shortTopicId, source: 'test', title: 'Recent', message: 'Should survive' })
.returning()
await runRetentionCleanup()
const oldStillExists = await db
.select()
.from(notifications)
.where(eq(notifications.id, oldNotification!.id))
expect(oldStillExists).toHaveLength(0)
const recentStillExists = await db
.select()
.from(notifications)
.where(eq(notifications.id, recentNotification!.id))
expect(recentStillExists).toHaveLength(1)
})
it('never deletes notifications in a retentionDays=0 ("forever") topic', async () => {
const tenYearsAgo = new Date(Date.now() - 10 * 365 * 24 * 60 * 60 * 1000)
const [veryOldNotification] = await db
.insert(notifications)
.values({
topicId: foreverTopicId,
source: 'test',
title: 'Ancient',
message: 'Should never be cleaned up',
createdAt: tenYearsAgo,
})
.returning()
await runRetentionCleanup()
const stillExists = await db
.select()
.from(notifications)
.where(eq(notifications.id, veryOldNotification!.id))
expect(stillExists).toHaveLength(1)
})
})
@@ -0,0 +1,41 @@
import { and, eq, lt } from 'drizzle-orm'
import { db } from '../../db/client.js'
import { notifications, topics } from '../../db/schema.js'
import { resolveRetentionDefaultDays } from '../admin/system-settings.js'
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000 // 1 hour
// Per-topic retentionDays overrides the global default; 0 means "forever".
// See docs/phase1/03-database-model.md, "Retention cleanup".
export async function runRetentionCleanup(): Promise<number> {
const topicRows = await db.select().from(topics)
const retentionDefaultDays = await resolveRetentionDefaultDays()
let totalDeleted = 0
for (const topic of topicRows) {
const retentionDays = topic.retentionDays ?? retentionDefaultDays
if (retentionDays === 0) continue
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)
const deleted = await db
.delete(notifications)
.where(and(eq(notifications.topicId, topic.id), lt(notifications.createdAt, cutoff)))
.returning({ id: notifications.id })
totalDeleted += deleted.length
}
return totalDeleted
}
export function startRetentionCleanupJob(
onError: (err: unknown) => void = console.error,
): NodeJS.Timeout {
const run = () => {
runRetentionCleanup().catch(onError)
}
run() // catch up on anything overdue from before this boot
const timer = setInterval(run, CLEANUP_INTERVAL_MS)
timer.unref() // never block process shutdown just to keep this alive
return timer
}
@@ -0,0 +1,175 @@
import type { FastifyInstance } from 'fastify'
import { requireAdmin, requireAuth } from '../auth/guards.js'
import { writeAuditLog } from '../audit/log.js'
import {
deleteNotification,
getNotificationForUser,
listNotificationsForUser,
markAllRead,
setNotificationState,
} from './repository.js'
const notificationSchema = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
topicId: { type: 'string', format: 'uuid' },
topicSlug: { type: 'string' },
topicDisplayName: { type: 'string' },
source: { type: 'string' },
title: { type: 'string' },
message: { type: 'string' },
externalUrl: { type: ['string', 'null'] },
imageUrl: { type: ['string', 'null'] },
occurrenceCount: { type: 'integer' },
rawPriority: { type: ['string', 'null'] },
read: { type: 'boolean' },
starred: { type: 'boolean' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
} as const
// Web listener (:8080) — read/mutate own read+star state. See
// ingest-routes.ts for the LAN publish endpoint on :8081. Full-text search
// here is a simple ILIKE, not the GIN-indexed tsvector search described in
// docs/phase1/03-database-model.md — good enough at homelab scale, upgrade
// path noted there if it's ever needed.
export async function registerNotificationReadRoutes(app: FastifyInstance) {
app.get(
'/api/v1/notifications',
{
preHandler: requireAuth,
schema: {
querystring: {
type: 'object',
properties: {
topic: { type: 'string' },
unread: { type: 'boolean' },
starred: { type: 'boolean' },
q: { type: 'string' },
limit: { type: 'integer', minimum: 1, maximum: 200 },
},
},
response: {
200: {
type: 'object',
properties: { notifications: { type: 'array', items: notificationSchema } },
},
},
},
},
async (req) => {
const query = req.query as {
topic?: string
unread?: boolean
starred?: boolean
q?: string
limit?: number
}
const notifications = await listNotificationsForUser(req.currentUser!.id, {
topicSlug: query.topic,
unreadOnly: query.unread,
starredOnly: query.starred,
q: query.q,
limit: query.limit,
})
return { notifications }
},
)
app.post(
'/api/v1/notifications/mark-all-read',
{
preHandler: requireAuth,
schema: {
querystring: { type: 'object', properties: { topic: { type: 'string' } } },
},
},
async (req) => {
const { topic } = req.query as { topic?: string }
const count = await markAllRead(req.currentUser!.id, topic)
return { status: 'ok', count }
},
)
app.get(
'/api/v1/notifications/:id',
{
preHandler: requireAuth,
schema: {
params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
response: {
200: { type: 'object', properties: { notification: notificationSchema } },
404: { type: 'object' },
},
},
},
async (req, reply) => {
const { id } = req.params as { id: string }
const notification = await getNotificationForUser(req.currentUser!.id, id)
if (!notification) {
reply.code(404)
return { error: 'not_found' }
}
return { notification }
},
)
app.patch(
'/api/v1/notifications/:id',
{
preHandler: requireAuth,
schema: {
params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
body: {
type: 'object',
properties: { read: { type: 'boolean' }, starred: { type: 'boolean' } },
},
},
},
async (req, reply) => {
const { id } = req.params as { id: string }
const changes = req.body as { read?: boolean; starred?: boolean }
const existing = await getNotificationForUser(req.currentUser!.id, id)
if (!existing) {
reply.code(404)
return { error: 'not_found' }
}
await setNotificationState(req.currentUser!.id, id, changes)
const updated = await getNotificationForUser(req.currentUser!.id, id)
return { notification: updated }
},
)
// Admin-only: notifications are shared across every user who can see the
// topic, so deleting one isn't a per-user action the way read/star are.
app.delete(
'/api/v1/notifications/:id',
{
preHandler: requireAdmin,
schema: {
params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
},
},
async (req, reply) => {
const { id } = req.params as { id: string }
const deleted = await deleteNotification(id)
if (!deleted) {
reply.code(404)
return { error: 'not_found' }
}
await writeAuditLog({
actorUserId: req.currentUser!.id,
action: 'notification.delete',
targetType: 'notification',
targetId: deleted.id,
metadata: { title: deleted.title, topicId: deleted.topicId },
ipAddress: req.ip,
})
return { status: 'ok' }
},
)
}
@@ -0,0 +1,42 @@
import type { FastifyInstance } from 'fastify'
import { requireAuth } from '../auth/guards.js'
import { notificationEvents } from './events.js'
const HEARTBEAT_INTERVAL_MS = 25_000
// Server-Sent Events stream for live UI updates while a tab is open — see
// docs/phase1/01-architecture.md's realtime decision (SSE, no Redis, no
// WebSocket). Push notifications (Phase 7) cover the tab-closed case.
export async function registerStreamRoutes(app: FastifyInstance) {
app.get('/api/v1/stream', { preHandler: requireAuth }, async (req, reply) => {
reply.hijack()
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const send = (event: string, data: unknown) => {
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
}
send('connected', { ok: true })
const onCreated = (notification: unknown) => send('notification.created', notification)
const onUpdated = (notification: unknown) => send('notification.updated', notification)
notificationEvents.on('notification.created', onCreated)
notificationEvents.on('notification.updated', onUpdated)
const heartbeat = setInterval(() => {
reply.raw.write(': heartbeat\n\n')
}, HEARTBEAT_INTERVAL_MS)
req.raw.on('close', () => {
clearInterval(heartbeat)
notificationEvents.off('notification.created', onCreated)
notificationEvents.off('notification.updated', onUpdated)
reply.raw.end()
})
})
}
@@ -0,0 +1,41 @@
import type { FastifyInstance } from 'fastify'
import { requireApiToken } from '../tokens/ingest-auth.js'
import { publishAndRespond } from './publish.js'
// Generic webhook endpoint — MVP is a fixed simple mapping, not a
// configurable templating engine (docs/phase1/00-README.md decision #6:
// "the goal is easy migration, not full protocol cloning"). `:source`
// becomes the notification's source field.
export async function registerWebhookRoutes(app: FastifyInstance) {
app.post(
'/api/v1/webhook/:source',
{
preHandler: requireApiToken,
schema: {
params: { type: 'object', properties: { source: { type: 'string' } }, required: ['source'] },
body: {
type: 'object',
required: ['topic', 'title', 'message'],
properties: {
topic: { type: 'string', minLength: 1 },
title: { type: 'string', minLength: 1, maxLength: 500 },
message: { type: 'string', minLength: 1, maxLength: 10000 },
url: { type: 'string' },
},
},
},
},
async (req, reply) => {
const { source } = req.params as { source: string }
const body = req.body as { topic: string; title: string; message: string; url?: string }
await publishAndRespond(req, reply, {
topicSlug: body.topic,
source,
title: body.title,
message: body.message,
externalUrl: body.url ?? null,
})
},
)
}
+127
View File
@@ -0,0 +1,127 @@
import { and, eq } from 'drizzle-orm'
import type { Notification } from '../../db/schema.js'
import { db } from '../../db/client.js'
import { users, userTopicSubscriptions } from '../../db/schema.js'
import { isQuietPriority, isWithinQuietHours, resolvePriority } from '../../lib/priority.js'
import { resolveQuietHours, resolveVapidSubject } from '../admin/system-settings.js'
import { getTopicBySlug } from '../topics/repository.js'
import {
getActiveSubscriptionsForUser,
disableSubscription,
recordDeliveryAttempt,
} from './repository.js'
import { ensureVapidConfigured, webpush } from './vapid.js'
interface PushPayload {
notificationId: string
topicSlug: string
title: string
body: string
icon: string
}
async function isPushEnabledForUser(userId: string, topicId: string): Promise<boolean> {
const [subscription] = await db
.select()
.from(userTopicSubscriptions)
.where(and(eq(userTopicSubscriptions.userId, userId), eq(userTopicSubscriptions.topicId, topicId)))
.limit(1)
// Absence of a row means push is on by default, per
// docs/phase1/03-database-model.md. A snooze (mutedUntil in the future)
// suppresses push independently of the on/off preference and just
// expires on its own — nothing needs to clear it.
if (!(subscription?.pushEnabled ?? true)) return false
if (subscription?.mutedUntil && subscription.mutedUntil.getTime() > Date.now()) return false
return true
}
// Fire-and-forget from modules/notifications/publish.ts — ingestion must
// never block on push delivery (docs/phase1/04-api-design.md). Every
// failure here is caught and recorded, never thrown back at the caller.
export async function fanOutPush(notification: Notification, topicSlug: string): Promise<void> {
try {
// A publisher (e.g. a Home Assistant automation) can mark a specific
// notification "min priority" to keep it in its normal topic — visible
// in history like any other — without waking anyone's phone for it.
if (isQuietPriority(notification.rawPriority)) return
// Household-wide quiet hours (Admin > System): during the configured
// window, only notifications at/above minPriority still push — lower
// ones are still stored and shown in-app, same as isQuietPriority.
const quietHours = await resolveQuietHours()
if (
quietHours.enabled &&
resolvePriority(notification.rawPriority) < quietHours.minPriority &&
isWithinQuietHours(quietHours.start, quietHours.end, quietHours.timezone)
) {
return
}
await ensureVapidConfigured(await resolveVapidSubject())
const topic = await getTopicBySlug(topicSlug)
if (!topic) return
const topicDisplayName = topic.displayName
const allUsers = await db.select().from(users)
for (const user of allUsers) {
const enabled = await isPushEnabledForUser(user.id, notification.topicId)
if (!enabled) continue
const devices = await getActiveSubscriptionsForUser(user.id)
if (devices.length === 0) continue
const payload: PushPayload = {
notificationId: notification.id,
topicSlug,
title: notification.title || topicDisplayName,
body: notification.message,
icon: '/icons/icon-192.png',
}
for (const device of devices) {
try {
await webpush.sendNotification(
{
endpoint: device.endpoint,
keys: { p256dh: device.p256dh, auth: device.auth },
},
JSON.stringify(payload),
)
await recordDeliveryAttempt({
notificationId: notification.id,
pushSubscriptionId: device.id,
status: 'accepted_by_push_service',
})
} catch (err) {
const statusCode = (err as { statusCode?: number }).statusCode
const message = err instanceof Error ? err.message : String(err)
if (statusCode === 404 || statusCode === 410) {
await disableSubscription(device.id)
await recordDeliveryAttempt({
notificationId: notification.id,
pushSubscriptionId: device.id,
status: 'expired',
httpStatus: statusCode,
errorMessage: 'subscription no longer valid, disabled',
})
} else {
await recordDeliveryAttempt({
notificationId: notification.id,
pushSubscriptionId: device.id,
status: 'failed',
httpStatus: statusCode ?? null,
errorMessage: message.slice(0, 500),
})
}
}
}
}
} catch (err) {
// VAPID not configured, DB hiccup, etc. — never let a push problem
// affect the publish path that triggered this.
console.error('push fan-out failed:', err)
}
}
@@ -0,0 +1,88 @@
import { randomUUID } from 'node:crypto'
import { eq } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db, pool } from '../../db/client.js'
import { runMigrations } from '../../db/migrate.js'
import { users } from '../../db/schema.js'
import { deleteUserDevice, listUserDevices, upsertPushSubscription } from './repository.js'
// Integration tests — need a real, migrated Postgres. Run with:
// docker compose -f docker/docker-compose.yml exec notizarr \
// pnpm --filter @notizarr/server test
describe('push subscription ownership', () => {
let ownerId: string
let otherUserId: string
beforeAll(async () => {
await runMigrations()
const [owner] = await db
.insert(users)
.values({
email: `owner-${randomUUID()}@example.test`,
displayName: 'Owner',
authSubject: `owner-${randomUUID()}`,
})
.returning()
ownerId = owner!.id
const [other] = await db
.insert(users)
.values({
email: `other-${randomUUID()}@example.test`,
displayName: 'Other',
authSubject: `other-${randomUUID()}`,
})
.returning()
otherUserId = other!.id
})
afterAll(async () => {
await db.delete(users).where(eq(users.id, ownerId))
await db.delete(users).where(eq(users.id, otherUserId))
await pool.end()
})
it("prevents deleting another user's device", async () => {
const device = await upsertPushSubscription({
userId: ownerId,
endpoint: `https://push.example.test/${randomUUID()}`,
p256dh: 'test-p256dh',
auth: 'test-auth',
})
const deletedByWrongUser = await deleteUserDevice(otherUserId, device.id)
expect(deletedByWrongUser).toBeNull()
const stillThere = await listUserDevices(ownerId)
expect(stillThere.some((d) => d.id === device.id)).toBe(true)
const deletedByOwner = await deleteUserDevice(ownerId, device.id)
expect(deletedByOwner?.id).toBe(device.id)
const goneNow = await listUserDevices(ownerId)
expect(goneNow.some((d) => d.id === device.id)).toBe(false)
})
it('resubscribing the same endpoint updates the existing row instead of duplicating it', async () => {
const endpoint = `https://push.example.test/${randomUUID()}`
const first = await upsertPushSubscription({
userId: ownerId,
endpoint,
p256dh: 'key-1',
auth: 'auth-1',
})
const second = await upsertPushSubscription({
userId: ownerId,
endpoint,
p256dh: 'key-2',
auth: 'auth-2',
})
expect(second.id).toBe(first.id)
expect(second.p256dh).toBe('key-2')
await deleteUserDevice(ownerId, first.id)
})
})
+113
View File
@@ -0,0 +1,113 @@
import { and, desc, eq, isNull } from 'drizzle-orm'
import { db } from '../../db/client.js'
import { pushDeliveryAttempts, pushSubscriptions } from '../../db/schema.js'
export interface UpsertSubscriptionInput {
userId: string
endpoint: string
p256dh: string
auth: string
userAgent?: string | null
}
// Keyed by endpoint (unique) — resubscribing (e.g. after clearing site
// data) just refreshes the existing row rather than creating a duplicate.
export async function upsertPushSubscription(input: UpsertSubscriptionInput) {
const [existing] = await db
.select()
.from(pushSubscriptions)
.where(eq(pushSubscriptions.endpoint, input.endpoint))
.limit(1)
if (existing) {
const [updated] = await db
.update(pushSubscriptions)
.set({
userId: input.userId,
p256dh: input.p256dh,
auth: input.auth,
userAgent: input.userAgent ?? null,
lastSeenAt: new Date(),
disabledAt: null,
})
.where(eq(pushSubscriptions.id, existing.id))
.returning()
// Updating a row just found by its own id always returns exactly one row.
return updated!
}
const [created] = await db
.insert(pushSubscriptions)
.values({
userId: input.userId,
endpoint: input.endpoint,
p256dh: input.p256dh,
auth: input.auth,
userAgent: input.userAgent ?? null,
})
.returning()
// A single-row insert always returns exactly one row.
return created!
}
export async function listUserDevices(userId: string) {
return db
.select()
.from(pushSubscriptions)
.where(eq(pushSubscriptions.userId, userId))
.orderBy(desc(pushSubscriptions.createdAt))
}
export async function deleteUserDevice(userId: string, id: string) {
const [deleted] = await db
.delete(pushSubscriptions)
.where(and(eq(pushSubscriptions.id, id), eq(pushSubscriptions.userId, userId)))
.returning()
return deleted ?? null
}
export async function disableSubscription(id: string) {
await db.update(pushSubscriptions).set({ disabledAt: new Date() }).where(eq(pushSubscriptions.id, id))
}
export async function getActiveSubscriptionsForUser(userId: string) {
return db
.select()
.from(pushSubscriptions)
.where(and(eq(pushSubscriptions.userId, userId), isNull(pushSubscriptions.disabledAt)))
}
export interface RecordDeliveryAttemptInput {
notificationId: string
pushSubscriptionId: string
status: 'accepted_by_push_service' | 'failed' | 'invalid_subscription' | 'expired'
httpStatus?: number | null
errorMessage?: string | null
}
export async function recordDeliveryAttempt(input: RecordDeliveryAttemptInput) {
await db.insert(pushDeliveryAttempts).values({
notificationId: input.notificationId,
pushSubscriptionId: input.pushSubscriptionId,
status: input.status,
httpStatus: input.httpStatus ?? null,
errorMessage: input.errorMessage ?? null,
})
}
export async function getLastAttemptsForUserDevices(userId: string) {
const devices = await listUserDevices(userId)
const results: Record<string, (typeof pushDeliveryAttempts.$inferSelect) | null> = {}
for (const device of devices) {
const [last] = await db
.select()
.from(pushDeliveryAttempts)
.where(eq(pushDeliveryAttempts.pushSubscriptionId, device.id))
.orderBy(desc(pushDeliveryAttempts.attemptedAt))
.limit(1)
results[device.id] = last ?? null
}
return results
}
+103
View File
@@ -0,0 +1,103 @@
import type { FastifyInstance } from 'fastify'
import { requireAuth } from '../auth/guards.js'
import { resolveVapidSubject } from '../admin/system-settings.js'
import {
deleteUserDevice,
getLastAttemptsForUserDevices,
listUserDevices,
upsertPushSubscription,
} from './repository.js'
import { getVapidPublicKey } from './vapid.js'
export async function registerPushRoutes(app: FastifyInstance) {
app.get('/api/v1/push/vapid-public-key', { preHandler: requireAuth }, async () => {
const publicKey = await getVapidPublicKey(await resolveVapidSubject())
return { publicKey }
})
app.post(
'/api/v1/push/subscribe',
{
preHandler: requireAuth,
schema: {
body: {
type: 'object',
required: ['endpoint', 'keys'],
properties: {
endpoint: { type: 'string', minLength: 1 },
keys: {
type: 'object',
required: ['p256dh', 'auth'],
properties: {
p256dh: { type: 'string', minLength: 1 },
auth: { type: 'string', minLength: 1 },
},
},
userAgent: { type: 'string' },
},
},
},
},
async (req, reply) => {
const body = req.body as {
endpoint: string
keys: { p256dh: string; auth: string }
userAgent?: string
}
const subscription = await upsertPushSubscription({
userId: req.currentUser!.id,
endpoint: body.endpoint,
p256dh: body.keys.p256dh,
auth: body.keys.auth,
userAgent: body.userAgent ?? req.headers['user-agent'] ?? null,
})
reply.code(201)
return { subscription }
},
)
app.get('/api/v1/devices', { preHandler: requireAuth }, async (req) => {
const devices = await listUserDevices(req.currentUser!.id)
return { devices }
})
app.delete(
'/api/v1/devices/:id',
{
preHandler: requireAuth,
schema: {
params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
},
},
async (req, reply) => {
const { id } = req.params as { id: string }
const deleted = await deleteUserDevice(req.currentUser!.id, id)
if (!deleted) {
reply.code(404)
return { error: 'not_found' }
}
return { status: 'ok' }
},
)
app.get('/api/v1/push/diagnostics', { preHandler: requireAuth }, async (req) => {
const devices = await listUserDevices(req.currentUser!.id)
const lastAttempts = await getLastAttemptsForUserDevices(req.currentUser!.id)
const vapidConfigured = Boolean(await getVapidPublicKey(await resolveVapidSubject()))
return {
vapidConfigured,
devices: devices.map((device) => ({
id: device.id,
deviceLabel: device.deviceLabel,
userAgent: device.userAgent,
createdAt: device.createdAt,
lastSeenAt: device.lastSeenAt,
disabledAt: device.disabledAt,
lastDeliveryAttempt: lastAttempts[device.id] ?? null,
})),
}
})
}
+34
View File
@@ -0,0 +1,34 @@
import webpush from 'web-push'
import { getOrCreateSetting } from '../../db/settings.js'
const VAPID_KEYS_SETTING = 'vapid_keys'
export interface VapidKeyPair {
publicKey: string
privateKey: string
}
let keysPromise: Promise<VapidKeyPair> | null = null
// The keypair is generated once on first boot and persisted in Postgres —
// same durability pattern as the session secret — so that part is safe to
// cache forever. The subject is NOT cached with it: it's resolved fresh by
// the caller (Admin > System is DB-editable, see
// modules/admin/system-settings.js) on every call, and re-applying it to
// the `web-push` library here is cheap, so a subject change takes effect
// on the very next push without needing a restart.
export async function ensureVapidConfigured(subject: string): Promise<VapidKeyPair> {
if (!keysPromise) {
keysPromise = getOrCreateSetting<VapidKeyPair>(VAPID_KEYS_SETTING, () => webpush.generateVAPIDKeys())
}
const keys = await keysPromise
webpush.setVapidDetails(subject, keys.publicKey, keys.privateKey)
return keys
}
export async function getVapidPublicKey(subject: string): Promise<string> {
const keys = await ensureVapidConfigured(subject)
return keys.publicKey
}
export { webpush }
+25
View File
@@ -0,0 +1,25 @@
import { createHash, randomBytes } from 'node:crypto'
const TOKEN_PREFIX = 'nzr_'
export interface GeneratedToken {
plaintext: string
hash: string
prefix: string
}
// Plaintext is shown to the admin exactly once; only the hash is ever
// stored (docs/phase1/02-threat-model.md, "API token theft / abuse").
export function generateApiToken(): GeneratedToken {
const raw = randomBytes(32).toString('base64url')
const plaintext = `${TOKEN_PREFIX}${raw}`
return {
plaintext,
hash: hashApiToken(plaintext),
prefix: plaintext.slice(0, 12),
}
}
export function hashApiToken(plaintext: string): string {
return createHash('sha256').update(plaintext).digest('hex')
}
@@ -0,0 +1,100 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import type { ApiToken } from '../../db/schema.js'
import { config } from '../../config.js'
import { isIpInAnyCidr } from '../../lib/cidr.js'
import { writeAuditLog } from '../audit/log.js'
import { ingestRateLimiter } from './rate-limit.js'
import {
findActiveTokenByPlaintext,
getAllowedTopicSlugsForToken,
touchTokenUsage,
} from './repository.js'
declare module 'fastify' {
interface FastifyRequest {
apiToken?: ApiToken
apiTokenAllowedTopics?: string[] | null
}
}
function rateLimited(reply: FastifyReply) {
reply.code(429)
reply.header('Retry-After', '10')
return reply.send({
error: 'rate_limited',
message: 'too many requests',
retryAfterSeconds: 10,
})
}
// Ingestion listener preHandler: validates source address, rate limits,
// and the bearer token's identity. Per-request topic/source scope checks
// (which need the parsed body) happen in modules/notifications/publish.ts.
export async function requireApiToken(req: FastifyRequest, reply: FastifyReply) {
const peerIp = req.socket.remoteAddress
if (!peerIp || !isIpInAnyCidr(peerIp, config.ingestionAllowedCidrs)) {
reply.code(403)
return reply.send({ error: 'forbidden', message: 'source address not allowed' })
}
// IP-level limiting runs before token validation too, so a broken or
// malicious client can't use auth failures to sidestep rate limiting
// (e.g. brute-forcing tokens).
if (!ingestRateLimiter.check(`ip:${peerIp}`)) {
return rateLimited(reply)
}
// Some third-party apps (webhook destinations that only expose a bare
// URL field, no custom headers) have no way to send an Authorization
// header — accept the token as a query param in that case. This is
// opt-in per request, not a replacement for header auth: prefer the
// header wherever the client can set one, since a URL can end up in
// server logs, browser history, or a Referer header, which a header
// never does.
const authHeader = req.headers.authorization
const queryToken = (req.query as Record<string, string | undefined>).token
let plaintext: string
if (authHeader?.startsWith('Bearer ')) {
plaintext = authHeader.slice('Bearer '.length).trim()
} else if (queryToken) {
plaintext = queryToken
} else {
reply.code(401)
return reply.send({
error: 'unauthorized',
message: 'missing bearer token (Authorization header or ?token= query param)',
})
}
const token = await findActiveTokenByPlaintext(plaintext)
if (!token) {
reply.code(401)
return reply.send({ error: 'unauthorized', message: 'invalid, revoked, or expired token' })
}
if (!ingestRateLimiter.check(`token:${token.id}`)) {
await writeAuditLog({
actorTokenId: token.id,
action: 'rate_limit.rejected',
targetType: 'api_token',
targetId: token.id,
ipAddress: peerIp,
})
return rateLimited(reply)
}
if (
token.allowedCidrs &&
token.allowedCidrs.length > 0 &&
!isIpInAnyCidr(peerIp, token.allowedCidrs)
) {
reply.code(403)
return reply.send({ error: 'forbidden', message: 'source address not allowed for this token' })
}
req.apiToken = token
req.apiTokenAllowedTopics = await getAllowedTopicSlugsForToken(token.id)
await touchTokenUsage(token.id)
}
@@ -0,0 +1,11 @@
import { config } from '../../config.js'
import { createDualWindowLimiter } from '../../lib/rate-limiter.js'
// One limiter instance shared by every key (per-token, per-IP) — see
// docs/phase1/04-api-design.md, "Rate limiting (listener B)".
export const ingestRateLimiter = createDualWindowLimiter(
config.rateLimit.sustainedPerMinute,
60_000,
config.rateLimit.burstPer10Seconds,
10_000,
)
@@ -0,0 +1,88 @@
import { randomUUID } from 'node:crypto'
import { eq } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db, pool } from '../../db/client.js'
import { runMigrations } from '../../db/migrate.js'
import { topics, users } from '../../db/schema.js'
import {
createToken,
findActiveTokenByPlaintext,
getAllowedTopicSlugsForToken,
revokeToken,
} from './repository.js'
// Integration tests — need a real, migrated Postgres reachable via
// DATABASE_URL. Run with:
// docker compose -f docker/docker-compose.yml exec notizarr \
// pnpm --filter @notizarr/server test
describe('api token repository', () => {
const testSlug = `test-topic-${randomUUID()}`
const testAuthSubject = `test-user-${randomUUID()}`
let userId: string
let topicId: string
beforeAll(async () => {
await runMigrations()
const [user] = await db
.insert(users)
.values({
email: `${testAuthSubject}@example.test`,
displayName: 'Test User',
authSubject: testAuthSubject,
role: 'admin',
})
.returning()
userId = user!.id
const [topic] = await db
.insert(topics)
.values({ slug: testSlug, displayName: 'Test Topic', createdBy: userId })
.returning()
topicId = topic!.id
})
afterAll(async () => {
await db.delete(topics).where(eq(topics.id, topicId))
await db.delete(users).where(eq(users.id, userId))
await pool.end()
})
it('creates a token, resolves it by plaintext, then rejects it once revoked', async () => {
const { token, plaintext } = await createToken({ name: 'Test Token', createdBy: userId })
const resolved = await findActiveTokenByPlaintext(plaintext)
expect(resolved?.id).toBe(token.id)
await revokeToken(token.id)
const afterRevoke = await findActiveTokenByPlaintext(plaintext)
expect(afterRevoke).toBeNull()
})
it('scopes a token to specific topics', async () => {
const { token } = await createToken({
name: 'Scoped Token',
createdBy: userId,
topicSlugs: [testSlug],
})
const allowed = await getAllowedTopicSlugsForToken(token.id)
expect(allowed).toEqual([testSlug])
})
it('treats an unscoped token as allowed for every topic', async () => {
const { token } = await createToken({ name: 'Unscoped Token', createdBy: userId })
const allowed = await getAllowedTopicSlugsForToken(token.id)
expect(allowed).toBeNull()
})
it('rejects an already-expired token', async () => {
const { plaintext } = await createToken({
name: 'Expired Token',
createdBy: userId,
expiresAt: new Date(Date.now() - 1000),
})
const resolved = await findActiveTokenByPlaintext(plaintext)
expect(resolved).toBeNull()
})
})
@@ -0,0 +1,117 @@
import { eq, inArray, sql } from 'drizzle-orm'
import { db } from '../../db/client.js'
import { apiTokens, apiTokenTopics, topics } from '../../db/schema.js'
import { generateApiToken, hashApiToken } from './crypto.js'
export interface CreateTokenInput {
name: string
allowedSource?: string | null
allowedCidrs?: string[] | null
expiresAt?: Date | null
topicSlugs?: string[]
createdBy: string
}
export async function createToken(input: CreateTokenInput) {
const generated = generateApiToken()
const [token] = await db
.insert(apiTokens)
.values({
name: input.name,
tokenHash: generated.hash,
tokenPrefix: generated.prefix,
allowedSource: input.allowedSource ?? null,
allowedCidrs: input.allowedCidrs ?? null,
expiresAt: input.expiresAt ?? null,
createdBy: input.createdBy,
})
.returning()
// A single-row insert always returns exactly one row.
const insertedToken = token!
if (input.topicSlugs && input.topicSlugs.length > 0) {
const topicRows = await db.select().from(topics).where(inArray(topics.slug, input.topicSlugs))
if (topicRows.length > 0) {
await db
.insert(apiTokenTopics)
.values(topicRows.map((t) => ({ tokenId: insertedToken.id, topicId: t.id })))
}
}
return { token: insertedToken, plaintext: generated.plaintext }
}
export async function listTokens() {
return db.select().from(apiTokens).orderBy(apiTokens.createdAt)
}
export async function getTokenById(id: string) {
const [token] = await db.select().from(apiTokens).where(eq(apiTokens.id, id)).limit(1)
return token ?? null
}
export async function revokeToken(id: string) {
const [token] = await db
.update(apiTokens)
.set({ revokedAt: new Date() })
.where(eq(apiTokens.id, id))
.returning()
return token ?? null
}
export type DeleteTokenResult = 'deleted' | 'not_found' | 'still_active'
// Hard delete, unlike revokeToken — only permitted once a token is already
// revoked, so an admin can't accidentally erase the audit trail of a token
// that's still live. api_token_topics rows for it cascade via the FK
// onDelete: 'cascade' constraint in db/schema.ts.
export async function deleteToken(id: string): Promise<DeleteTokenResult> {
const token = await getTokenById(id)
if (!token) return 'not_found'
if (!token.revokedAt) return 'still_active'
await db.delete(apiTokens).where(eq(apiTokens.id, id))
return 'deleted'
}
// Keeps the token's name/scopes/expiry policy; only the secret changes.
export async function rotateToken(id: string) {
const generated = generateApiToken()
const [token] = await db
.update(apiTokens)
.set({ tokenHash: generated.hash, tokenPrefix: generated.prefix, revokedAt: null })
.where(eq(apiTokens.id, id))
.returning()
if (!token) return null
return { token, plaintext: generated.plaintext }
}
export async function findActiveTokenByPlaintext(plaintext: string) {
const hash = hashApiToken(plaintext)
const [token] = await db.select().from(apiTokens).where(eq(apiTokens.tokenHash, hash)).limit(1)
if (!token) return null
if (token.revokedAt) return null
if (token.expiresAt && token.expiresAt.getTime() < Date.now()) return null
return token
}
export async function touchTokenUsage(id: string) {
await db
.update(apiTokens)
.set({ lastUsedAt: new Date(), usageCount: sql`${apiTokens.usageCount} + 1` })
.where(eq(apiTokens.id, id))
}
// null = unrestricted (token may publish to any topic).
export async function getAllowedTopicSlugsForToken(tokenId: string): Promise<string[] | null> {
const rows = await db
.select({ slug: topics.slug })
.from(apiTokenTopics)
.innerJoin(topics, eq(apiTokenTopics.topicId, topics.id))
.where(eq(apiTokenTopics.tokenId, tokenId))
if (rows.length === 0) return null
return rows.map((r) => r.slug)
}
+138
View File
@@ -0,0 +1,138 @@
import type { FastifyInstance } from 'fastify'
import type { ApiToken } from '../../db/schema.js'
import { requireAdmin } from '../auth/guards.js'
import { writeAuditLog } from '../audit/log.js'
import { createToken, deleteToken, listTokens, revokeToken, rotateToken } from './repository.js'
// Never includes tokenHash.
function toPublicToken(token: ApiToken) {
return {
id: token.id,
name: token.name,
tokenPrefix: token.tokenPrefix,
allowedSource: token.allowedSource,
allowedCidrs: token.allowedCidrs,
expiresAt: token.expiresAt,
revokedAt: token.revokedAt,
createdAt: token.createdAt,
lastUsedAt: token.lastUsedAt,
usageCount: token.usageCount,
}
}
export async function registerTokenRoutes(app: FastifyInstance) {
app.get('/api/v1/admin/tokens', { preHandler: requireAdmin }, async () => {
const tokens = await listTokens()
return { tokens: tokens.map(toPublicToken) }
})
app.post(
'/api/v1/admin/tokens',
{
preHandler: requireAdmin,
schema: {
body: {
type: 'object',
required: ['name'],
properties: {
name: { type: 'string', minLength: 1, maxLength: 200 },
allowedSource: { type: 'string' },
allowedCidrs: { type: 'array', items: { type: 'string' } },
expiresAt: { type: 'string', format: 'date-time' },
topicSlugs: { type: 'array', items: { type: 'string' } },
},
},
},
},
async (req, reply) => {
const body = req.body as {
name: string
allowedSource?: string
allowedCidrs?: string[]
expiresAt?: string
topicSlugs?: string[]
}
const { token, plaintext } = await createToken({
name: body.name,
allowedSource: body.allowedSource ?? null,
allowedCidrs: body.allowedCidrs ?? null,
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
topicSlugs: body.topicSlugs,
createdBy: req.currentUser!.id,
})
await writeAuditLog({
actorUserId: req.currentUser!.id,
action: 'token.create',
targetType: 'api_token',
targetId: token.id,
metadata: { name: token.name },
ipAddress: req.ip,
})
reply.code(201)
// plaintext is only ever returned here, at creation time.
return { token: toPublicToken(token), plaintext }
},
)
app.post('/api/v1/admin/tokens/:id/rotate', { preHandler: requireAdmin }, async (req, reply) => {
const { id } = req.params as { id: string }
const result = await rotateToken(id)
if (!result) {
reply.code(404)
return { error: 'not_found' }
}
await writeAuditLog({
actorUserId: req.currentUser!.id,
action: 'token.rotate',
targetType: 'api_token',
targetId: result.token.id,
metadata: { name: result.token.name },
ipAddress: req.ip,
})
return { token: toPublicToken(result.token), plaintext: result.plaintext }
})
app.delete('/api/v1/admin/tokens/:id', { preHandler: requireAdmin }, async (req, reply) => {
const { id } = req.params as { id: string }
const token = await revokeToken(id)
if (!token) {
reply.code(404)
return { error: 'not_found' }
}
await writeAuditLog({
actorUserId: req.currentUser!.id,
action: 'token.revoke',
targetType: 'api_token',
targetId: token.id,
metadata: { name: token.name },
ipAddress: req.ip,
})
return { token: toPublicToken(token) }
})
// Hard delete — only for tokens already revoked via the endpoint above,
// so this can't be used to erase an active token's audit trail.
app.delete('/api/v1/admin/tokens/:id/purge', { preHandler: requireAdmin }, async (req, reply) => {
const { id } = req.params as { id: string }
const result = await deleteToken(id)
if (result === 'not_found') {
reply.code(404)
return { error: 'not_found' }
}
if (result === 'still_active') {
reply.code(400)
return { error: 'still_active', message: 'revoke the token before deleting it' }
}
await writeAuditLog({
actorUserId: req.currentUser!.id,
action: 'token.delete',
targetType: 'api_token',
targetId: id,
ipAddress: req.ip,
})
return { status: 'ok' }
})
}
@@ -0,0 +1,199 @@
import { and, eq, sql } from 'drizzle-orm'
import { db } from '../../db/client.js'
import { notifications, topics, userNotificationState, userTopicSubscriptions } from '../../db/schema.js'
export async function listTopics() {
return db.select().from(topics).orderBy(topics.displayName)
}
export interface TopicWithStats {
id: string
slug: string
displayName: string
description: string | null
icon: string | null
accentColor: string | null
retentionDays: number | null
restricted: boolean
createdAt: Date
updatedAt: Date
totalCount: number
unreadCount: number
latestAt: Date | null
pushEnabled: boolean
mutedUntil: Date | null
}
type TopicRow = typeof topics.$inferSelect
// Shared by listTopicsWithStats (all topics) and getTopicWithStatsBySlug
// (one topic, e.g. the topic detail page's push toggle) so the two never
// drift out of sync — a prior version of this file computed pushEnabled
// only in the list query, which silently made the detail page's toggle
// always read as "on" regardless of actual state.
async function attachStats(userId: string, topic: TopicRow): Promise<TopicWithStats> {
const [counts] = await db
.select({
totalCount: sql<number>`count(*)::int`,
latestAt: sql<Date | null>`max(${notifications.createdAt})`,
})
.from(notifications)
.where(eq(notifications.topicId, topic.id))
const [unread] = await db
.select({ unreadCount: sql<number>`count(*)::int` })
.from(notifications)
.leftJoin(
userNotificationState,
and(
eq(userNotificationState.notificationId, notifications.id),
eq(userNotificationState.userId, userId),
),
)
.where(
and(
eq(notifications.topicId, topic.id),
sql`coalesce(${userNotificationState.read}, false) = false`,
),
)
const [subscription] = await db
.select()
.from(userTopicSubscriptions)
.where(and(eq(userTopicSubscriptions.userId, userId), eq(userTopicSubscriptions.topicId, topic.id)))
.limit(1)
return {
...topic,
totalCount: counts?.totalCount ?? 0,
latestAt: counts?.latestAt ?? null,
unreadCount: unread?.unreadCount ?? 0,
pushEnabled: subscription?.pushEnabled ?? true,
mutedUntil: subscription?.mutedUntil ?? null,
}
}
// One extra pair of small queries per topic rather than a single mega-join —
// simpler to get right and fine at homelab scale (a handful to a few dozen
// topics), matching the "keep it simple" principle in
// docs/phase1/00-README.md. Can become a single aggregated query later if
// topic counts ever make that worthwhile.
export async function listTopicsWithStats(userId: string): Promise<TopicWithStats[]> {
const topicRows = await listTopics()
return Promise.all(topicRows.map((topic) => attachStats(userId, topic)))
}
export async function getTopicWithStatsBySlug(
userId: string,
slug: string,
): Promise<TopicWithStats | null> {
const [topic] = await db.select().from(topics).where(eq(topics.slug, slug)).limit(1)
if (!topic) return null
return attachStats(userId, topic)
}
interface SubscriptionPatch {
pushEnabled?: boolean
mutedUntil?: Date | null
}
async function upsertTopicSubscription(userId: string, topicId: string, patch: SubscriptionPatch) {
const [existing] = await db
.select()
.from(userTopicSubscriptions)
.where(and(eq(userTopicSubscriptions.userId, userId), eq(userTopicSubscriptions.topicId, topicId)))
.limit(1)
if (existing) {
const [updated] = await db
.update(userTopicSubscriptions)
.set({ ...patch, updatedAt: new Date() })
.where(eq(userTopicSubscriptions.id, existing.id))
.returning()
return updated
}
const [created] = await db
.insert(userTopicSubscriptions)
.values({ userId, topicId, pushEnabled: true, ...patch })
.returning()
return created
}
export async function setTopicPushEnabled(userId: string, topicId: string, pushEnabled: boolean) {
return upsertTopicSubscription(userId, topicId, { pushEnabled })
}
// Temporary mute — set mutedUntil to null to clear it early. Independent of
// pushEnabled, so it always expires back to whatever the user's actual
// on/off preference already was.
export async function setTopicMutedUntil(userId: string, topicId: string, mutedUntil: Date | null) {
return upsertTopicSubscription(userId, topicId, { mutedUntil })
}
export async function getTopicBySlug(slug: string) {
const [topic] = await db.select().from(topics).where(eq(topics.slug, slug)).limit(1)
return topic ?? null
}
export interface CreateTopicInput {
slug: string
displayName: string
description?: string | null
icon?: string | null
accentColor?: string | null
retentionDays?: number | null
restricted?: boolean
createdBy: string
}
export async function createTopic(input: CreateTopicInput) {
const [topic] = await db
.insert(topics)
.values({
slug: input.slug,
displayName: input.displayName,
description: input.description ?? null,
icon: input.icon ?? null,
accentColor: input.accentColor ?? null,
retentionDays: input.retentionDays ?? null,
restricted: input.restricted ?? false,
createdBy: input.createdBy,
})
.returning()
// A single-row insert always returns exactly one row.
return topic!
}
export interface UpdateTopicInput {
displayName?: string
description?: string | null
icon?: string | null
accentColor?: string | null
retentionDays?: number | null
restricted?: boolean
archived?: boolean
}
export async function updateTopic(id: string, input: UpdateTopicInput) {
const { archived, ...rest } = input
const [updated] = await db
.update(topics)
.set({
...rest,
...(archived !== undefined ? { archivedAt: archived ? new Date() : null } : {}),
updatedAt: new Date(),
})
.where(eq(topics.id, id))
.returning()
return updated ?? null
}
// Permanent — cascades to notifications, user_notification_state,
// push_delivery_attempts, and api_token_topics rows for this topic via the
// FK onDelete: 'cascade' constraints in db/schema.ts.
export async function deleteTopic(id: string) {
const [deleted] = await db.delete(topics).where(eq(topics.id, id)).returning()
return deleted ?? null
}
+273
View File
@@ -0,0 +1,273 @@
import type { FastifyInstance } from 'fastify'
import { requireAdmin, requireAuth } from '../auth/guards.js'
import { writeAuditLog } from '../audit/log.js'
import {
createTopic,
deleteTopic,
getTopicBySlug,
getTopicWithStatsBySlug,
listTopicsWithStats,
setTopicMutedUntil,
setTopicPushEnabled,
updateTopic,
} from './repository.js'
const topicSchema = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
slug: { type: 'string' },
displayName: { type: 'string' },
description: { type: ['string', 'null'] },
icon: { type: ['string', 'null'] },
accentColor: { type: ['string', 'null'] },
retentionDays: { type: ['integer', 'null'] },
restricted: { type: 'boolean' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
} as const
const topicWithStatsSchema = {
type: 'object',
properties: {
...topicSchema.properties,
totalCount: { type: 'integer' },
unreadCount: { type: 'integer' },
latestAt: { type: ['string', 'null'], format: 'date-time' },
pushEnabled: { type: 'boolean' },
mutedUntil: { type: ['string', 'null'], format: 'date-time' },
},
} as const
// Read routes require a logged-in user (any role); per-topic visibility
// filtering for `restricted` topics is Phase 8. The create route lives at
// its final Phase-8 path, /api/v1/admin/topics, gated by requireAdmin.
export async function registerTopicRoutes(app: FastifyInstance) {
app.get(
'/api/v1/topics',
{
preHandler: requireAuth,
schema: {
response: {
200: {
type: 'object',
properties: { topics: { type: 'array', items: topicWithStatsSchema } },
},
},
},
},
async (req) => {
const topics = await listTopicsWithStats(req.currentUser!.id)
return { topics }
},
)
app.get(
'/api/v1/topics/:slug',
{
preHandler: requireAuth,
schema: {
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
response: {
200: { type: 'object', properties: { topic: topicWithStatsSchema } },
404: { type: 'object' },
},
},
},
async (req, reply) => {
const { slug } = req.params as { slug: string }
const topic = await getTopicWithStatsBySlug(req.currentUser!.id, slug)
if (!topic) {
reply.code(404)
return { error: 'not_found' }
}
return { topic }
},
)
app.patch(
'/api/v1/topics/:slug/subscription',
{
preHandler: requireAuth,
schema: {
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
body: {
type: 'object',
required: ['pushEnabled'],
properties: { pushEnabled: { type: 'boolean' } },
},
},
},
async (req, reply) => {
const { slug } = req.params as { slug: string }
const { pushEnabled } = req.body as { pushEnabled: boolean }
const topic = await getTopicBySlug(slug)
if (!topic) {
reply.code(404)
return { error: 'not_found' }
}
const subscription = await setTopicPushEnabled(req.currentUser!.id, topic.id, pushEnabled)
return { subscription }
},
)
app.patch(
'/api/v1/topics/:slug/snooze',
{
preHandler: requireAuth,
schema: {
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
body: {
type: 'object',
// Minutes from now to snooze push for, or null to clear an
// existing snooze early — independent of the pushEnabled toggle.
properties: { minutes: { type: ['integer', 'null'], minimum: 1 } },
},
},
},
async (req, reply) => {
const { slug } = req.params as { slug: string }
const { minutes } = req.body as { minutes: number | null }
const topic = await getTopicBySlug(slug)
if (!topic) {
reply.code(404)
return { error: 'not_found' }
}
const mutedUntil = minutes ? new Date(Date.now() + minutes * 60_000) : null
const subscription = await setTopicMutedUntil(req.currentUser!.id, topic.id, mutedUntil)
return { subscription }
},
)
app.post(
'/api/v1/admin/topics',
{
preHandler: requireAdmin,
schema: {
body: {
type: 'object',
required: ['slug', 'displayName'],
properties: {
slug: { type: 'string', minLength: 1, maxLength: 100, pattern: '^[a-zA-Z0-9_-]+$' },
displayName: { type: 'string', minLength: 1, maxLength: 200 },
description: { type: 'string' },
icon: { type: 'string' },
accentColor: { type: 'string' },
retentionDays: { type: 'integer', minimum: 0 },
restricted: { type: 'boolean' },
},
},
response: {
201: { type: 'object', properties: { topic: topicSchema } },
409: { type: 'object' },
},
},
},
async (req, reply) => {
const body = req.body as {
slug: string
displayName: string
description?: string
icon?: string
accentColor?: string
retentionDays?: number
restricted?: boolean
}
const existing = await getTopicBySlug(body.slug)
if (existing) {
reply.code(409)
return { error: 'conflict', message: 'a topic with this slug already exists' }
}
const topic = await createTopic({ ...body, createdBy: req.currentUser!.id })
await writeAuditLog({
actorUserId: req.currentUser!.id,
action: 'topic.create',
targetType: 'topic',
targetId: topic.id,
metadata: { slug: topic.slug, displayName: topic.displayName },
ipAddress: req.ip,
})
reply.code(201)
return { topic }
},
)
app.patch(
'/api/v1/admin/topics/:id',
{
preHandler: requireAdmin,
schema: {
params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
body: {
type: 'object',
properties: {
displayName: { type: 'string', minLength: 1, maxLength: 200 },
description: { type: 'string' },
icon: { type: 'string' },
accentColor: { type: 'string' },
retentionDays: { type: 'integer', minimum: 0 },
restricted: { type: 'boolean' },
archived: { type: 'boolean' },
},
},
response: {
200: { type: 'object', properties: { topic: topicSchema } },
404: { type: 'object' },
},
},
},
async (req, reply) => {
const { id } = req.params as { id: string }
const body = req.body as Parameters<typeof updateTopic>[1]
const topic = await updateTopic(id, body)
if (!topic) {
reply.code(404)
return { error: 'not_found' }
}
await writeAuditLog({
actorUserId: req.currentUser!.id,
action: 'topic.update',
targetType: 'topic',
targetId: topic.id,
metadata: body as Record<string, unknown>,
ipAddress: req.ip,
})
return { topic }
},
)
app.delete(
'/api/v1/admin/topics/:id',
{
preHandler: requireAdmin,
schema: {
params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
},
},
async (req, reply) => {
const { id } = req.params as { id: string }
const topic = await deleteTopic(id)
if (!topic) {
reply.code(404)
return { error: 'not_found' }
}
await writeAuditLog({
actorUserId: req.currentUser!.id,
action: 'topic.delete',
targetType: 'topic',
targetId: topic.id,
metadata: { slug: topic.slug, displayName: topic.displayName },
ipAddress: req.ip,
})
return { status: 'ok' }
},
)
}