Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
import fp from "fastify-plugin";
import fastifyJwt from "@fastify/jwt";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { eq } from "drizzle-orm";
import { schema } from "@app/database";
import type { UserRole } from "@app/shared-types";
import { errors } from "../lib/errors.js";
export interface AccessTokenPayload {
sub: string;
role: UserRole;
type: "access";
/** true endast när inloggningen gick via TOTP-steget (admin-2FA). */
mfa?: boolean;
}
declare module "fastify" {
interface FastifyInstance {
authenticate: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
requireAdmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
interface FastifyRequest {
userId: string;
userRole: UserRole;
tokenMfa: boolean;
}
}
export const authPlugin = fp(async (app: FastifyInstance) => {
await app.register(fastifyJwt, {
secret: app.config.JWT_ACCESS_SECRET,
sign: { expiresIn: app.config.JWT_ACCESS_TTL_SECONDS },
});
app.decorateRequest("userId", "");
app.decorateRequest("userRole", "user" as UserRole);
app.decorateRequest("tokenMfa", false);
app.decorate("authenticate", async (req: FastifyRequest) => {
let payload: AccessTokenPayload;
try {
payload = await req.jwtVerify<AccessTokenPayload>();
} catch {
throw errors.unauthorized();
}
if (payload.type !== "access" || !payload.sub) throw errors.unauthorized();
req.userId = payload.sub;
req.userRole = payload.role;
req.tokenMfa = payload.mfa === true;
});
app.decorate("requireAdmin", async (req: FastifyRequest) => {
await app.authenticate(req, undefined as unknown as FastifyReply);
if (req.userRole !== "admin") {
// Dubbelkolla mot databasen rollen i en gammal token räcker inte.
const [user] = await app.db
.select({ role: schema.users.role })
.from(schema.users)
.where(eq(schema.users.id, req.userId))
.limit(1);
if (user?.role !== "admin") throw errors.forbidden("Kräver admin.");
req.userRole = "admin";
}
// 2FA-tvång: har kontot TOTP aktiverat måste token vara MFA-utfärdad
// (via /v1/auth/totp-verify). En lösenords-token räcker inte för admin-API.
if (!req.tokenMfa) {
const [totp] = await app.db
.select({ enabledAt: schema.adminTotp.enabledAt })
.from(schema.adminTotp)
.where(eq(schema.adminTotp.userId, req.userId))
.limit(1);
if (totp?.enabledAt) {
throw errors.forbidden("Tvåfaktorsautentisering krävs. Logga in igen med engångskod.");
}
}
});
});
+201
View File
@@ -0,0 +1,201 @@
import fp from "fastify-plugin";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { randomUUID } from "node:crypto";
import { Queue } from "bullmq";
import IORedis from "ioredis";
import { createDatabase, type Database } from "@app/database";
import { schema } from "@app/database";
import { createAamosClient, type AamosClient } from "@app/ai-contracts";
import { FeatureFlagService } from "@app/feature-flags";
import { StoreVerifier } from "@app/subscriptions";
import { createMailer, type Mailer } from "../lib/mailer.js";
import { createDefaultRegistry, type ConnectorRegistry } from "@app/connectors";
import type { AppConfig } from "../config.js";
import { ApiError } from "../lib/errors.js";
declare module "fastify" {
interface FastifyInstance {
config: AppConfig;
db: Database;
dbPool: { end(): Promise<void> };
jobQueue: Queue;
redis: IORedis;
aamos: AamosClient;
flags: FeatureFlagService;
storeVerifier: StoreVerifier;
mailer: Mailer;
connectors: ConnectorRegistry;
}
interface FastifyRequest {
correlationId: string;
}
}
import { JOB_QUEUE_NAME } from "@app/shared-types";
export { JOB_QUEUE_NAME };
/**
* Kärnplugin: db, redis/kö, AAMOS-klient, flaggor, correlation-id,
* säkerhetsheaders och enhetlig felhantering.
*/
export const corePlugin = fp(async (app: FastifyInstance, opts: { config: AppConfig }) => {
const { config } = opts;
app.decorate("config", config);
// --- Databas ---
const { db, pool } = createDatabase(config.DATABASE_URL);
app.decorate("db", db);
app.decorate("dbPool", pool);
app.addHook("onClose", async () => {
await pool.end();
});
// --- Redis + jobbkö ---
const redis = new IORedis(config.REDIS_URL, { maxRetriesPerRequest: null, lazyConnect: true });
redis.on("error", (err) => app.log.warn({ err }, "Redis-fel"));
app.decorate("redis", redis);
const jobQueue = new Queue(JOB_QUEUE_NAME, {
connection: redis,
defaultJobOptions: {
attempts: 3,
backoff: { type: "exponential", delay: 2_000 },
removeOnComplete: 1_000,
removeOnFail: 5_000,
},
});
app.decorate("jobQueue", jobQueue);
app.addHook("onClose", async () => {
await jobQueue.close();
redis.disconnect();
});
// --- AAMOS (befintlig plattform; mock endast i dev/test) ---
app.decorate(
"aamos",
createAamosClient({
AAMOS_MODE: config.AAMOS_MODE,
AAMOS_API_URL: config.AAMOS_API_URL,
AAMOS_API_KEY: config.AAMOS_API_KEY,
AAMOS_TIMEOUT_MS: String(config.AAMOS_TIMEOUT_MS),
}),
);
// --- Feature flags ---
app.decorate(
"flags",
new FeatureFlagService({
loadAll: async () => {
const rows = await db.select().from(schema.featureFlags);
return rows.map((r) => ({
key: r.key,
enabled: r.enabled,
rolloutPercent: r.rolloutPercent,
}));
},
}),
);
// --- Butiksverifiering ---
app.decorate(
"storeVerifier",
new StoreVerifier({
mode: config.APP_STORE_MODE,
appleBundleId: config.APPLE_BUNDLE_ID,
googlePackageName: config.GOOGLE_PACKAGE_NAME,
}),
);
// --- E-post (log-läge i dev; leverantör kopplas i fas 7) ---
app.decorate(
"mailer",
createMailer(config.EMAIL_MODE, {
host: config.SMTP_HOST,
port: config.SMTP_PORT,
secure: config.SMTP_SECURE,
user: config.SMTP_USER,
pass: config.SMTP_PASS,
from: config.MAIL_FROM,
}),
);
// --- Connectors ---
app.decorate("connectors", createDefaultRegistry());
// --- Correlation ID (spec §58) ---
app.decorateRequest("correlationId", "");
app.addHook("onRequest", async (req: FastifyRequest, reply: FastifyReply) => {
const incoming = req.headers["x-correlation-id"];
req.correlationId =
typeof incoming === "string" && incoming.length <= 100 ? incoming : randomUUID();
reply.header("x-correlation-id", req.correlationId);
});
// --- Säkerhetsheaders ---
app.addHook("onSend", async (_req, reply) => {
reply.header("x-content-type-options", "nosniff");
reply.header("x-frame-options", "DENY");
reply.header("referrer-policy", "no-referrer");
reply.header("cache-control", "no-store");
});
// --- Enhetlig felhantering ---
const alertWebhook = async (text: string): Promise<void> => {
const url = process.env.ERROR_WEBHOOK_URL;
if (!url) return;
try {
await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
signal: AbortSignal.timeout(5000),
});
} catch {
// Larmet får aldrig påverka svaret till klienten.
}
};
app.setErrorHandler((err, req, reply) => {
if (err instanceof ApiError) {
reply.status(err.statusCode).send({
error: {
code: err.code,
message: err.message,
details: err.details,
correlationId: req.correlationId,
},
});
return;
}
// Fastify-inbyggda (t.ex. rate limit, felaktig JSON)
const known = err as { statusCode?: unknown; message?: unknown };
const status = typeof known.statusCode === "number" ? known.statusCode : 500;
if (status >= 500) {
req.log.error({ err, correlationId: req.correlationId }, "Ohanterat fel");
void alertWebhook(
`API 5xx: ${req.method} ${req.url} ${String(known.message ?? "okänt fel")} (correlationId: ${req.correlationId})`,
);
}
reply.status(status).send({
error: {
code: status === 429 ? "RATE_LIMITED" : status >= 500 ? "INTERNAL" : "REQUEST_ERROR",
message:
status >= 500
? "Internt fel försök igen."
: typeof known.message === "string"
? known.message
: "Ogiltig förfrågan.",
correlationId: req.correlationId,
},
});
});
app.setNotFoundHandler((req, reply) => {
reply.status(404).send({
error: {
code: "NOT_FOUND",
message: "Endpointen finns inte.",
correlationId: req.correlationId,
},
});
});
});
+246
View File
@@ -0,0 +1,246 @@
import fp from "fastify-plugin";
import type { FastifyInstance } from "fastify";
import { createHmac, randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
/**
* Lagringsabstraktion (spec §53). Två lägen:
*
* - mock (dev/test): "presignade" URL:er pekar på API:ts egna PUT/GET-endpoints
* och filer lagras under .data/s3/. Hela flödet (app → signed upload →
* worker läser) fungerar utan AWS.
* - aws (staging/produktion): RIKTIGA S3 presigned URLs via @aws-sdk/client-s3
* + @aws-sdk/s3-request-presigner. Fungerar mot AWS S3 och alla
* S3-kompatibla lagringar (MinIO, Cloudflare R2, Hetzner …) via S3_ENDPOINT.
* Verifierad med riktig rundtur (presign → PUT → presign → GET) mot MinIO.
*
* Nyckelstruktur enligt spec §53: users/, fridge-scans/, pantry-scans/,
* meal-scans/, receipts/, product-images/, recipe-images/, temporary/.
*/
import {
GetObjectCommand,
HeadBucketCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export interface PresignedUpload {
key: string;
uploadUrl: string;
method: "PUT";
headers: Record<string, string>;
expiresAt: string;
}
export interface StorageService {
presignUpload(prefix: string, contentType: string): Promise<PresignedUpload>;
/** URL som workern/AAMOS kan läsa bilden från. */
getReadUrl(key: string): Promise<string>;
putObject(key: string, data: Buffer, contentType: string): Promise<void>;
getObject(key: string): Promise<Buffer | null>;
}
declare module "fastify" {
interface FastifyInstance {
storage: StorageService;
}
}
const MOCK_ROOT = path.resolve(process.cwd(), ".data/s3");
class MockStorage implements StorageService {
constructor(
private readonly baseUrl: string,
private readonly signingSecret: string,
) {}
private sign(key: string): string {
return createHmac("sha256", this.signingSecret).update(key).digest("hex").slice(0, 32);
}
async presignUpload(prefix: string, contentType: string): Promise<PresignedUpload> {
const ext = contentType.split("/")[1] ?? "bin";
const key = `${prefix}/${randomUUID()}.${ext}`;
const sig = this.sign(key);
return {
key,
uploadUrl: `${this.baseUrl}/v1/mock-s3/${encodeURIComponent(key)}?sig=${sig}`,
method: "PUT",
headers: { "content-type": contentType },
expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
};
}
async getReadUrl(key: string): Promise<string> {
return `${this.baseUrl}/v1/mock-s3/${encodeURIComponent(key)}?sig=${this.sign(key)}`;
}
async putObject(key: string, data: Buffer, _contentType?: string): Promise<void> {
const filePath = path.join(MOCK_ROOT, key);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, data);
}
async getObject(key: string): Promise<Buffer | null> {
try {
return await readFile(path.join(MOCK_ROOT, key));
} catch {
return null;
}
}
verifySignature(key: string, sig: string): boolean {
return this.sign(key) === sig;
}
}
/** Riktig S3 (AWS eller S3-kompatibel via S3_ENDPOINT). */
export class AwsStorage implements StorageService {
private readonly client: S3Client;
constructor(
private readonly bucket: string,
region: string,
endpoint: string | undefined,
accessKeyId: string | undefined,
secretAccessKey: string | undefined,
) {
this.client = new S3Client({
region,
...(endpoint ? { endpoint, forcePathStyle: true } : {}),
...(accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}), // annars IAM-roll/instansprofil (rekommenderat i AWS)
});
}
/** Snabb verifiering vid uppstart hellre högljutt fel än trasiga uppladdningar. */
async healthCheck(): Promise<void> {
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
}
async presignUpload(prefix: string, contentType: string): Promise<PresignedUpload> {
const ext = contentType.split("/")[1] ?? "bin";
const key = `${prefix}/${randomUUID()}.${ext}`;
const uploadUrl = await getSignedUrl(
this.client,
new PutObjectCommand({ Bucket: this.bucket, Key: key, ContentType: contentType }),
{ expiresIn: 15 * 60 },
);
return {
key,
uploadUrl,
method: "PUT",
headers: { "content-type": contentType },
expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
};
}
async getReadUrl(key: string): Promise<string> {
return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), {
expiresIn: 60 * 60,
});
}
async putObject(key: string, data: Buffer, contentType: string): Promise<void> {
await this.client.send(
new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: data, ContentType: contentType }),
);
}
async getObject(key: string): Promise<Buffer | null> {
try {
const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
const bytes = await res.Body?.transformToByteArray();
return bytes ? Buffer.from(bytes) : null;
} catch {
return null;
}
}
}
export const storagePlugin = fp(async (app: FastifyInstance) => {
let storage: StorageService;
if (app.config.S3_MODE === "aws") {
const aws = new AwsStorage(
app.config.S3_BUCKET,
app.config.S3_REGION,
app.config.S3_ENDPOINT || undefined,
app.config.S3_ACCESS_KEY_ID || undefined,
app.config.S3_SECRET_ACCESS_KEY || undefined,
);
// Verifiera bucket-åtkomst vid uppstart produktion ska aldrig starta halvtrasig.
try {
await aws.healthCheck();
app.log.info(`S3: riktig lagring aktiv (bucket: ${app.config.S3_BUCKET})`);
} catch (err) {
if (app.config.NODE_ENV === "production") {
throw new Error(
`S3-bucketen "${app.config.S3_BUCKET}" är inte nåbar: ${(err as Error).message}`,
);
}
app.log.error(
`S3-bucketen "${app.config.S3_BUCKET}" svarar inte (${(err as Error).message}) kontrollera S3_*-värdena.`,
);
}
storage = aws;
app.decorate("storage", storage);
return; // riktiga presignade URL:er inga lokala S3-endpoints behövs
}
const mockStorage = new MockStorage(
app.config.API_BASE_URL,
app.config.ENTITLEMENT_SIGNING_SECRET,
);
storage = mockStorage;
app.decorate("storage", storage);
// Mock-S3-endpoints (endast i mock-läge)
app.addContentTypeParser(
["image/jpeg", "image/png", "image/webp", "image/heic", "application/octet-stream"],
{ parseAs: "buffer", bodyLimit: 15 * 1024 * 1024 },
(_req, body, done) => done(null, body),
);
app.put<{ Params: { key: string }; Querystring: { sig?: string } }>(
"/v1/mock-s3/:key",
{ config: { rateLimit: false } },
async (req, reply) => {
const key = decodeURIComponent(req.params.key);
if (!mockStorage.verifySignature(key, req.query.sig ?? "")) {
return reply
.status(403)
.send({ error: { code: "BAD_SIGNATURE", message: "Ogiltig signatur" } });
}
const body = req.body as Buffer | undefined;
if (!body || body.length === 0) {
return reply.status(400).send({ error: { code: "EMPTY_BODY", message: "Tom fil" } });
}
await mockStorage.putObject(
key,
body,
req.headers["content-type"] ?? "application/octet-stream",
);
return reply.status(200).send({ ok: true, key });
},
);
app.get<{ Params: { key: string }; Querystring: { sig?: string } }>(
"/v1/mock-s3/:key",
{ config: { rateLimit: false } },
async (req, reply) => {
const key = decodeURIComponent(req.params.key);
if (!mockStorage.verifySignature(key, req.query.sig ?? "")) {
return reply
.status(403)
.send({ error: { code: "BAD_SIGNATURE", message: "Ogiltig signatur" } });
}
const data = await mockStorage.getObject(key);
if (!data)
return reply
.status(404)
.send({ error: { code: "NOT_FOUND", message: "Filen finns inte" } });
return reply.header("content-type", "application/octet-stream").send(data);
},
);
});