Initial commit (unpacked platform)
This commit is contained in:
@@ -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.");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user