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:
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -25,12 +25,12 @@
|
||||
"@app/recipe-engine": "workspace:*",
|
||||
"@app/recommendation-engine": "workspace:*",
|
||||
"@app/shared-types": "workspace:*",
|
||||
"@app/storage": "workspace:*",
|
||||
"@app/subscriptions": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.750.0",
|
||||
"@aws-sdk/client-s3": "^3.1102.0",
|
||||
"bullmq": "^6.0.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"drizzle-orm": "^0.45.0",
|
||||
"@aws-sdk/client-s3": "^3.750.0",
|
||||
"ioredis": "^6.0.0",
|
||||
"pg": "^8.13.0"
|
||||
},
|
||||
@@ -50,6 +50,7 @@
|
||||
"@app/recipe-engine",
|
||||
"@app/recommendation-engine",
|
||||
"@app/shared-types",
|
||||
"@app/storage",
|
||||
"@app/subscriptions"
|
||||
]
|
||||
}
|
||||
|
||||
+27
-12
@@ -10,9 +10,9 @@ for (const candidate of [".env", "../.env", "../../.env"]) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
import { createHmac } from "node:crypto";
|
||||
import { createDatabase, type Database } from "@app/database";
|
||||
import { createAamosClient, type AamosClient, type BudgetStore } from "@app/ai-contracts";
|
||||
import { createStorageService, type StorageService } from "@app/storage";
|
||||
import type { Redis } from "ioredis";
|
||||
|
||||
export interface WorkerContext {
|
||||
@@ -21,8 +21,10 @@ export interface WorkerContext {
|
||||
redis?: Redis;
|
||||
/** Delad dagsbudget-store så att memory-jobbet kan spegla skanningens budgetkoll. */
|
||||
budgetStore?: BudgetStore;
|
||||
/** Bygger läs-URL för lagrade bilder (samma signaturlogik som API:ts mock-S3). */
|
||||
readUrl: (key: string) => string;
|
||||
/** Lagringstjänst för att läsa/skriva bilder (S3 eller mock). */
|
||||
storage: StorageService;
|
||||
/** Bygger läs-URL för lagrade bilder (presignerad S3 i aws-läge, mock-URL lokalt). */
|
||||
readUrl: (key: string) => Promise<string>;
|
||||
apiBaseUrl: string;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
@@ -64,29 +66,42 @@ class RedisBudgetStore implements BudgetStore {
|
||||
}
|
||||
}
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`Missing ${name}. Worker must be configured explicitly.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createContext(redis?: Redis): WorkerContext {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
throw new Error("Missing DATABASE_URL. Worker must connect explicitly to the app database.");
|
||||
}
|
||||
const databaseUrl = requireEnv("DATABASE_URL");
|
||||
const { db, pool } = createDatabase(databaseUrl);
|
||||
const budgetStore = redis
|
||||
? new RedisBudgetStore(redis, `gemini:daily:budget:${new Date().toISOString().slice(0, 10)}`)
|
||||
: undefined;
|
||||
const aamos = createAamosClient(undefined, { budgetStore });
|
||||
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:4000";
|
||||
const secret = process.env.ENTITLEMENT_SIGNING_SECRET ?? "dev-only-change-me-three";
|
||||
const signingSecret = requireEnv("ENTITLEMENT_SIGNING_SECRET");
|
||||
|
||||
const s3Mode = process.env.S3_MODE === "aws" ? "aws" : "mock";
|
||||
const storage = createStorageService({
|
||||
mode: s3Mode,
|
||||
baseUrl: apiBaseUrl,
|
||||
signingSecret,
|
||||
s3Bucket: process.env.S3_BUCKET ?? "",
|
||||
s3Region: process.env.S3_REGION ?? "",
|
||||
s3Endpoint: process.env.S3_ENDPOINT || undefined,
|
||||
s3AccessKeyId: process.env.S3_ACCESS_KEY_ID || undefined,
|
||||
s3SecretAccessKey: process.env.S3_SECRET_ACCESS_KEY || undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
db,
|
||||
aamos,
|
||||
redis,
|
||||
budgetStore,
|
||||
storage,
|
||||
apiBaseUrl,
|
||||
readUrl: (key: string) => {
|
||||
const sig = createHmac("sha256", secret).update(key).digest("hex").slice(0, 32);
|
||||
return `${apiBaseUrl}/v1/mock-s3/${encodeURIComponent(key)}?sig=${sig}`;
|
||||
},
|
||||
readUrl: (key: string) => storage.getReadUrl(key),
|
||||
close: async () => {
|
||||
await pool.end();
|
||||
},
|
||||
|
||||
@@ -47,7 +47,7 @@ async function main() {
|
||||
anonymizedImprovement: true,
|
||||
imageTraining: true,
|
||||
},
|
||||
readUrl: (key: string) => {
|
||||
readUrl: async (key: string) => {
|
||||
if (key === "eval/fridge-1.jpg") return fridgeDataUrl;
|
||||
return `http://localhost/v1/mock-s3/${encodeURIComponent(key)}?sig=dummy`;
|
||||
},
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface CaptureInput {
|
||||
inputTokens?: number | null;
|
||||
outputTokens?: number | null;
|
||||
consentFlags: CaptureConsentFlags;
|
||||
readUrl: (key: string) => string;
|
||||
readUrl: (key: string) => Promise<string>;
|
||||
/** Override for tests; otherwise built from env. */
|
||||
storage?: CaptureStorage;
|
||||
}
|
||||
@@ -138,7 +138,7 @@ export async function captureTrainingSample(input: CaptureInput): Promise<Captur
|
||||
if (!originalKey) continue;
|
||||
try {
|
||||
const imageKey = `${prefix}/images/${String(i).padStart(3, "0")}.jpg`;
|
||||
const imageData = await fetchImageData(input.readUrl(originalKey));
|
||||
const imageData = await fetchImageData(await input.readUrl(originalKey));
|
||||
if (imageData) {
|
||||
await storage.put(imageKey, imageData, "image/jpeg");
|
||||
trainingImageKeys.push(imageKey);
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function processScanJob(ctx: WorkerContext, scanJobId: string): Pro
|
||||
.set({ status: "running", attempts: job.attempts + 1, updatedAt: new Date() })
|
||||
.where(eq(schema.scanJobs.id, scanJobId));
|
||||
|
||||
const imageUrls = job.s3Keys.map((k) => ctx.readUrl(k));
|
||||
const imageUrls = await Promise.all(job.s3Keys.map((k) => ctx.readUrl(k)));
|
||||
const localeContext = await getLocaleContext(ctx, job.userId);
|
||||
const consentFlags = await loadConsentFlags(ctx, job.userId);
|
||||
|
||||
|
||||
@@ -86,7 +86,8 @@ describe("UPDATE_USER_MEMORY hardening", () => {
|
||||
aamos,
|
||||
budgetStore,
|
||||
apiBaseUrl: "http://localhost:4000",
|
||||
readUrl: (key: string) => `http://localhost:4000/mock-s3/${key}`,
|
||||
storage: {} as any,
|
||||
readUrl: async (key: string) => `http://localhost:4000/mock-s3/${key}`,
|
||||
close: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user