feat(storage,deploy): delad S3/mock-lagring, SSM .env-sync, Gemini readyz

- @app/storage med MockStorage + AwsStorage delas mellan API och worker

- Workerns readUrl() ger presignerad S3-URL i aws-läge (fixar mock-s3 404)

- sync-env-from-ssm.py hämtar /cibello/prod/* och skriver .env; first-deploy.sh kör det i prod

- /readyz returnerar 503 om app.aamos.healthCheck() misslyckas

- docs/SECRETS.md med rotations- och SSM-regler
This commit is contained in:
Sven (AAMOS AI)
2026-08-12 17:28:58 +07:00
parent 535e3e36e8
commit 6e2d794130
17 changed files with 516 additions and 241 deletions
+2
View File
@@ -24,6 +24,7 @@
"@app/recipe-engine": "workspace:*",
"@app/recommendation-engine": "workspace:*",
"@app/shared-types": "workspace:*",
"@app/storage": "workspace:*",
"@app/subscriptions": "workspace:*",
"@app/validation": "workspace:*",
"@aws-sdk/client-s3": "^3.1102.0",
@@ -60,6 +61,7 @@
"@app/recipe-engine",
"@app/recommendation-engine",
"@app/shared-types",
"@app/storage",
"@app/subscriptions",
"@app/validation"
]
+14 -211
View File
@@ -1,55 +1,11 @@
import fp from "fastify-plugin";
import type { FastifyInstance } from "fastify";
import { createHmac, randomUUID } from "node:crypto";
import { mkdir, readFile, rm, 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 {
DeleteObjectCommand,
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>;
/** GDPR / rättning: radera ett objekt. Får inte kasta om nyckeln saknas. */
deleteObject(key: string): Promise<void>;
/**
* Försök extrahera lagringsnyckeln ur en läs-URL. Returnerar null om URL:en
* inte känns igen (t.ex. extern bild). Används vid GDPR-radering så att vi
* kan radera objekt även när endast URL:en är sparad.
*/
extractKeyFromUrl(url: string): string | null;
}
MockStorage,
AwsStorage,
type StorageService,
createStorageService,
} from "@app/storage";
declare module "fastify" {
interface FastifyInstance {
@@ -57,171 +13,16 @@ declare module "fastify" {
}
}
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;
}
}
async deleteObject(key: string): Promise<void> {
// GDPR-radering får inte avbrytas av saknade filer; rm med force swallowar felet.
await rm(path.join(MOCK_ROOT, key), { force: true }).catch(() => {});
}
extractKeyFromUrl(url: string): string | null {
try {
const parsed = new URL(url);
const prefix = "/v1/mock-s3/";
if (!parsed.pathname.startsWith(prefix)) return null;
return decodeURIComponent(parsed.pathname.slice(prefix.length));
} 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;
}
}
async deleteObject(key: string): Promise<void> {
try {
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
} catch (err) {
const code = (err as { Code?: string }).Code;
// NoSuchKey ska inte avbryta GDPR-radering; andra fel loggas.
if (code !== "NoSuchKey" && code !== "NotFound") {
throw err;
}
}
}
extractKeyFromUrl(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.hostname.startsWith(`${this.bucket}.`)) {
return decodeURIComponent(parsed.pathname.replace(/^\//, ""));
}
const pathPrefix = `/${this.bucket}/`;
if (parsed.pathname.startsWith(pathPrefix)) {
return decodeURIComponent(parsed.pathname.slice(pathPrefix.length));
}
return 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,
);
const aws = new AwsStorage({
bucket: app.config.S3_BUCKET,
region: app.config.S3_REGION,
endpoint: app.config.S3_ENDPOINT || undefined,
accessKeyId: app.config.S3_ACCESS_KEY_ID || undefined,
secretAccessKey: app.config.S3_SECRET_ACCESS_KEY || undefined,
});
// Verifiera bucket-åtkomst vid uppstart produktion ska aldrig starta halvtrasig.
try {
await aws.healthCheck();
@@ -297,3 +98,5 @@ export const storagePlugin = fp(async (app: FastifyInstance) => {
},
);
});
export { createStorageService };
+5 -1
View File
@@ -13,10 +13,14 @@ export async function healthRoutes(app: FastifyInstance) {
app.get("/readyz", { config: { rateLimit: false } }, async (_req, reply) => {
try {
await app.db.execute(sql`SELECT 1`);
return { ok: true };
} catch {
return reply.status(503).send({ ok: false, reason: "database" });
}
const aamosHealth = await app.aamos.healthCheck();
if (!aamosHealth.ok) {
return reply.status(503).send({ ok: false, reason: "aamos", detail: aamosHealth.detail });
}
return { ok: true };
});
/** Enkel endpointöversikt i stället för tung OpenAPI-generering (se beslutslogg D-011). */