@de-otio/trellis 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/dist/env.d.ts +168 -0
  2. package/dist/env.d.ts.map +1 -1
  3. package/dist/env.js +155 -0
  4. package/dist/env.js.map +1 -1
  5. package/dist/lambda/media-completion-worker.d.ts +175 -0
  6. package/dist/lambda/media-completion-worker.d.ts.map +1 -0
  7. package/dist/lambda/media-completion-worker.js +373 -0
  8. package/dist/lambda/media-completion-worker.js.map +1 -0
  9. package/dist/lambda/media-processing-worker.d.ts +172 -1
  10. package/dist/lambda/media-processing-worker.d.ts.map +1 -1
  11. package/dist/lambda/media-processing-worker.js +343 -49
  12. package/dist/lambda/media-processing-worker.js.map +1 -1
  13. package/dist/lib/exif-stripper.d.ts +37 -22
  14. package/dist/lib/exif-stripper.d.ts.map +1 -1
  15. package/dist/lib/exif-stripper.js +101 -41
  16. package/dist/lib/exif-stripper.js.map +1 -1
  17. package/dist/lib/media/cas-keys.d.ts +63 -0
  18. package/dist/lib/media/cas-keys.d.ts.map +1 -0
  19. package/dist/lib/media/cas-keys.js +102 -0
  20. package/dist/lib/media/cas-keys.js.map +1 -0
  21. package/dist/lib/media/classify-worker-error.d.ts +48 -0
  22. package/dist/lib/media/classify-worker-error.d.ts.map +1 -0
  23. package/dist/lib/media/classify-worker-error.js +319 -0
  24. package/dist/lib/media/classify-worker-error.js.map +1 -0
  25. package/dist/lib/media/dedupe-key.d.ts +29 -0
  26. package/dist/lib/media/dedupe-key.d.ts.map +1 -0
  27. package/dist/lib/media/dedupe-key.js +49 -0
  28. package/dist/lib/media/dedupe-key.js.map +1 -0
  29. package/dist/lib/media/duration-cap.d.ts +30 -0
  30. package/dist/lib/media/duration-cap.d.ts.map +1 -0
  31. package/dist/lib/media/duration-cap.js +37 -0
  32. package/dist/lib/media/duration-cap.js.map +1 -0
  33. package/dist/lib/media/ffmpeg-args.d.ts +83 -0
  34. package/dist/lib/media/ffmpeg-args.d.ts.map +1 -0
  35. package/dist/lib/media/ffmpeg-args.js +119 -0
  36. package/dist/lib/media/ffmpeg-args.js.map +1 -0
  37. package/dist/lib/media/media-ports.d.ts +126 -0
  38. package/dist/lib/media/media-ports.d.ts.map +1 -0
  39. package/dist/lib/media/media-ports.js +129 -0
  40. package/dist/lib/media/media-ports.js.map +1 -0
  41. package/dist/lib/media/media-upsert.d.ts +55 -0
  42. package/dist/lib/media/media-upsert.d.ts.map +1 -0
  43. package/dist/lib/media/media-upsert.js +38 -0
  44. package/dist/lib/media/media-upsert.js.map +1 -0
  45. package/dist/lib/media/moderation-provider.d.ts +111 -0
  46. package/dist/lib/media/moderation-provider.d.ts.map +1 -0
  47. package/dist/lib/media/moderation-provider.js +130 -0
  48. package/dist/lib/media/moderation-provider.js.map +1 -0
  49. package/dist/lib/media/moderation-resolved-payload.d.ts +48 -0
  50. package/dist/lib/media/moderation-resolved-payload.d.ts.map +1 -0
  51. package/dist/lib/media/moderation-resolved-payload.js +37 -0
  52. package/dist/lib/media/moderation-resolved-payload.js.map +1 -0
  53. package/dist/lib/media/moderation-status.d.ts +98 -0
  54. package/dist/lib/media/moderation-status.d.ts.map +1 -0
  55. package/dist/lib/media/moderation-status.js +122 -0
  56. package/dist/lib/media/moderation-status.js.map +1 -0
  57. package/dist/lib/media/processing-types.d.ts +45 -0
  58. package/dist/lib/media/processing-types.d.ts.map +1 -0
  59. package/dist/lib/media/processing-types.js +9 -0
  60. package/dist/lib/media/processing-types.js.map +1 -0
  61. package/dist/lib/media/promote-decision.d.ts +64 -0
  62. package/dist/lib/media/promote-decision.d.ts.map +1 -0
  63. package/dist/lib/media/promote-decision.js +76 -0
  64. package/dist/lib/media/promote-decision.js.map +1 -0
  65. package/dist/lib/media/quota-check.d.ts +22 -0
  66. package/dist/lib/media/quota-check.d.ts.map +1 -0
  67. package/dist/lib/media/quota-check.js +42 -0
  68. package/dist/lib/media/quota-check.js.map +1 -0
  69. package/dist/lib/media/quota-types.d.ts +15 -0
  70. package/dist/lib/media/quota-types.d.ts.map +1 -0
  71. package/dist/lib/media/quota-types.js +9 -0
  72. package/dist/lib/media/quota-types.js.map +1 -0
  73. package/dist/lib/media/route-upload.d.ts +58 -0
  74. package/dist/lib/media/route-upload.d.ts.map +1 -0
  75. package/dist/lib/media/route-upload.js +80 -0
  76. package/dist/lib/media/route-upload.js.map +1 -0
  77. package/dist/lib/media/serve-gate.d.ts +51 -0
  78. package/dist/lib/media/serve-gate.d.ts.map +1 -0
  79. package/dist/lib/media/serve-gate.js +68 -0
  80. package/dist/lib/media/serve-gate.js.map +1 -0
  81. package/dist/lib/media/tenant-resolution.d.ts +42 -0
  82. package/dist/lib/media/tenant-resolution.d.ts.map +1 -0
  83. package/dist/lib/media/tenant-resolution.js +45 -0
  84. package/dist/lib/media/tenant-resolution.js.map +1 -0
  85. package/dist/lib/media/text-moderation.d.ts +28 -0
  86. package/dist/lib/media/text-moderation.d.ts.map +1 -0
  87. package/dist/lib/media/text-moderation.js +62 -0
  88. package/dist/lib/media/text-moderation.js.map +1 -0
  89. package/dist/lib/media/track-verdict.d.ts +45 -0
  90. package/dist/lib/media/track-verdict.d.ts.map +1 -0
  91. package/dist/lib/media/track-verdict.js +52 -0
  92. package/dist/lib/media/track-verdict.js.map +1 -0
  93. package/dist/lib/media/transcript-moderation.d.ts +47 -0
  94. package/dist/lib/media/transcript-moderation.d.ts.map +1 -0
  95. package/dist/lib/media/transcript-moderation.js +70 -0
  96. package/dist/lib/media/transcript-moderation.js.map +1 -0
  97. package/dist/lib/media-handler.d.ts.map +1 -1
  98. package/dist/lib/media-handler.js +15 -9
  99. package/dist/lib/media-handler.js.map +1 -1
  100. package/dist/lib/post-handler.d.ts.map +1 -1
  101. package/dist/lib/post-handler.js +4 -1
  102. package/dist/lib/post-handler.js.map +1 -1
  103. package/dist/lib/routes/media.d.ts +21 -0
  104. package/dist/lib/routes/media.d.ts.map +1 -1
  105. package/dist/lib/routes/media.js +584 -483
  106. package/dist/lib/routes/media.js.map +1 -1
  107. package/dist/lib/services/image-normalizer.d.ts +64 -6
  108. package/dist/lib/services/image-normalizer.d.ts.map +1 -1
  109. package/dist/lib/services/image-normalizer.js +88 -6
  110. package/dist/lib/services/image-normalizer.js.map +1 -1
  111. package/dist/lib/services/media-upload-service.d.ts +2 -2
  112. package/dist/lib/services/media-upload-service.d.ts.map +1 -1
  113. package/dist/lib/services/media-upload-service.js +22 -21
  114. package/dist/lib/services/media-upload-service.js.map +1 -1
  115. package/dist/lib/tenant-scope.d.ts.map +1 -1
  116. package/dist/lib/tenant-scope.js +16 -1
  117. package/dist/lib/tenant-scope.js.map +1 -1
  118. package/package.json +2 -1
  119. package/prisma/migrations/20260625000000_media_tenant_scope_and_moderation_status/migration.sql +49 -0
  120. package/prisma/migrations/20260625000001_p0b_moderation_jobs/migration.sql +73 -0
  121. package/prisma/schema.prisma +95 -17
  122. package/src/lambda/media-completion-worker.ts +567 -0
  123. package/src/lambda/media-processing-worker.ts +508 -59
@@ -1,60 +1,354 @@
1
- import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
1
+ // media-processing-worker.ts the P0b media-processing orchestration SHELL.
2
+ //
3
+ // This is the imperative shell over the pure functional-core media units. It is
4
+ // NOT itself a functional-core unit: it performs I/O (object storage, transcode,
5
+ // transcription, moderation, DB writes). BUT all of that I/O arrives through
6
+ // INJECTED capability seams (TranscodePort / StoragePort / TranscribePort /
7
+ // MediaModerationProvider) and a Prisma-shaped persistence port, so the
8
+ // orchestration logic is exercised in unit tests against the B0 in-memory Mocks
9
+ // — no real cloud, no real encoder, no real DB.
10
+ //
11
+ // Per the seam discipline (see lib/media/media-ports.ts and
12
+ // lib/media/moderation-provider.ts): CORE ships the interfaces + mocks; the
13
+ // consuming app (Skybber) injects the concrete cloud adapters at startup via
14
+ // `setMediaProcessingDeps()`. Until they are injected, the handler fails CLOSED
15
+ // (throws → SQS retry), never silently approves or drops work.
16
+ //
17
+ // Fail-closed posture, end to end:
18
+ // - A key that is not a well-formed `pending/{tenant}/{upload}` key is dropped
19
+ // (ack) and NEVER written under — the re-trigger-loop guard.
20
+ // - The tenant is re-derived FROM THE ROW, and the triggering key must equal
21
+ // pendingKey(rowTenant, uploadId); a mismatch is a hard reject (poison →
22
+ // REVIEW + ack), so a forged/odd key cannot make us moderate the wrong cas/.
23
+ // - Over-cap duration is poison → REVIEW + ack (no transcode attempted).
24
+ // - The worker ONLY starts moderation jobs + persists their jobIds; it never
25
+ // fetches verdicts (a separate poller owns fan-in). Moderation runs on the
26
+ // cleaned bytes at the STAGING key, NOT the raw pending upload — and the
27
+ // cleaned bytes are NOT written to cas/ here. cas/ is the CDN-served prefix,
28
+ // so it must only ever hold APPROVED cleaned bytes; the completion worker
29
+ // promotes staging -> cas/ on approval ("cleaned-staging, promote-on-approval").
30
+ // - classifyWorkerError() splits permanent media/payload defects (poison →
31
+ // REVIEW + ack, no DLQ loop) from transient infra faults (retryable → throw
32
+ // → SQS retry → DLQ + alert backstop).
2
33
  import { Logger } from "@aws-lambda-powertools/logger";
34
+ import { createHash } from "node:crypto";
35
+ import { pendingKey, casKey, isCasKeyError, } from "../lib/media/cas-keys.js";
36
+ import { exceedsDurationCap } from "../lib/media/duration-cap.js";
37
+ import { classifyWorkerError } from "../lib/media/classify-worker-error.js";
38
+ // ---------------------------------------------------------------------------
39
+ // Key parsing — pending/{tenantId}/{uploadId}
40
+ // ---------------------------------------------------------------------------
41
+ /**
42
+ * Parse a triggering key as a `pending/{tenantId}/{uploadId}` key, validating
43
+ * the FORM by round-tripping the parsed parts back through `pendingKey()`. A
44
+ * key only parses if rebuilding it from its parts yields the identical string —
45
+ * so a path-traversal payload, extra segments, or a malformed id can never pass
46
+ * (cas-keys.ts owns the anchored allowlists).
47
+ *
48
+ * @returns the {tenantId, uploadId} when the key is a canonical pending key,
49
+ * or null for ANY other key (which the caller ack-drops; we never
50
+ * write outputs under pending/, so a non-pending key is not our work).
51
+ */
52
+ export function parsePendingKey(key) {
53
+ const parts = key.split("/");
54
+ if (parts.length !== 3 || parts[0] !== "pending") {
55
+ return null;
56
+ }
57
+ const tenantId = parts[1];
58
+ const uploadId = parts[2];
59
+ const rebuilt = pendingKey(tenantId, uploadId);
60
+ if (isCasKeyError(rebuilt) || rebuilt !== key) {
61
+ return null;
62
+ }
63
+ return { tenantId, uploadId };
64
+ }
65
+ // ---------------------------------------------------------------------------
66
+ // S3-event-over-SQS extraction
67
+ // ---------------------------------------------------------------------------
68
+ /** Every object key referenced by one SQS record's S3 event notification. */
69
+ export function extractObjectKeys(recordBody) {
70
+ const parsed = JSON.parse(recordBody);
71
+ const s3Records = parsed.Records ?? [];
72
+ const keys = [];
73
+ for (const r of s3Records) {
74
+ const raw = r?.s3?.object?.key;
75
+ if (typeof raw === "string") {
76
+ // S3 URL-encodes keys and uses '+' for spaces in notifications.
77
+ keys.push(decodeURIComponent(raw.replace(/\+/g, " ")));
78
+ }
79
+ }
80
+ return keys;
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // Typed errors the orchestration core throws internally
84
+ // ---------------------------------------------------------------------------
85
+ /** A permanent payload defect: the key did not match the row's tenant/upload. */
86
+ class KeyTenantMismatchError extends Error {
87
+ constructor() {
88
+ // The name is in classify-worker-error's poison fragment set ("validation").
89
+ super("media key/tenant validation mismatch: triggering key does not match the row");
90
+ this.name = "ValidationError";
91
+ }
92
+ }
93
+ /** A permanent payload defect: the probed duration exceeds the configured cap. */
94
+ class DurationCapExceededError extends Error {
95
+ constructor() {
96
+ super("media duration cap exceeded");
97
+ this.name = "DurationCapExceeded";
98
+ }
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // Orchestration core — testable against the B0 Mocks
102
+ // ---------------------------------------------------------------------------
103
+ /**
104
+ * Orchestrate processing for ONE already-extracted object key.
105
+ *
106
+ * Steps (every uncertainty fails closed; nothing here can yield APPROVED):
107
+ * 1. Reject any key that is not a canonical `pending/{tenant}/{upload}` key —
108
+ * ack-drop it; outputs are NEVER written under pending/.
109
+ * 2. Load the MediaFile row by uploadId; re-derive tenant FROM THE ROW and
110
+ * assert pendingKey(rowTenant, uploadId) === the triggering key. Mismatch
111
+ * (or missing/uploadId-less row) is a hard reject → REVIEW + ack.
112
+ * 3. Probe duration; over-cap ⇒ poison ⇒ REVIEW + ack (no transcode).
113
+ * 4. Transcode-and-discard ⇒ cleaned bytes at the STAGING key (read back from
114
+ * the cleaned key). The cleaned bytes are NOT written to cas/ here — cas/ is
115
+ * the CDN-served prefix and must hold only APPROVED bytes (promotion happens
116
+ * in the completion worker).
117
+ * 5. Hash the cleaned bytes ⇒ realHash; PERSIST {contentHash: realHash,
118
+ * originalKey: casKey(tenant, realHash)} onto the row, replacing the
119
+ * upload-time uploadId placeholder so the completion worker can derive the
120
+ * promote target.
121
+ * 6. START moderation on the cleaned STAGING object (NOT the raw pending upload,
122
+ * NOT a cas/ key) — moderation must run on EXACTLY the bytes that will be
123
+ * served: provider.startVideoModeration ⇒ persist VISUAL job (+ threshold
124
+ * snapshot); transcribe.startTranscription ⇒ persist AUDIO job (+ snapshot).
125
+ * The worker only STARTS jobs + persists jobIds; it never fetches verdicts.
126
+ */
127
+ export async function processObjectKey(triggeringKey, deps) {
128
+ try {
129
+ // --- 1. Pending-key form gate (re-trigger-loop guard). ---
130
+ const parsed = parsePendingKey(triggeringKey);
131
+ if (parsed === null) {
132
+ deps.logger.info("Dropping non-pending key (not our work)", {
133
+ key: triggeringKey,
134
+ });
135
+ return { disposition: "ack", reason: "non-pending-key" };
136
+ }
137
+ const { uploadId } = parsed;
138
+ // --- 2. Load row; re-derive tenant FROM THE ROW; assert key match. ---
139
+ const row = await deps.persistence.findMediaByUploadId(uploadId);
140
+ if (row === null || row.uploadId === null) {
141
+ // No row, or a row that lost its upload session — cannot certify this
142
+ // object. Permanent w.r.t. these bytes: fail closed to human review.
143
+ throw new KeyTenantMismatchError();
144
+ }
145
+ const rowTenant = row.tenantId;
146
+ const expectedKey = pendingKey(rowTenant, uploadId);
147
+ if (isCasKeyError(expectedKey) || expectedKey !== triggeringKey) {
148
+ // The triggering key's tenant segment disagrees with the owning tenant,
149
+ // OR the row's tenant is itself malformed. Either way: hard reject.
150
+ throw new KeyTenantMismatchError();
151
+ }
152
+ // --- 3. Duration cap (probe BEFORE transcoding — cost + abuse guard). ---
153
+ const probed = await deps.transcode.probeDurationSeconds(triggeringKey);
154
+ if (exceedsDurationCap(probed, deps.config.maxDurationSeconds)) {
155
+ throw new DurationCapExceededError();
156
+ }
157
+ // --- 4. Transcode-and-discard ⇒ cleaned bytes. ---
158
+ // The cleaned output is written to a transient staging key OUTSIDE pending/
159
+ // (so re-uploading the cleaned bytes can never re-trigger this worker).
160
+ const cleanedStagingKey = `processing/${rowTenant}/${uploadId}`;
161
+ const posterStagingKey = `processing/${rowTenant}/${uploadId}.poster`;
162
+ const transcodeResult = await deps.transcode.transcodeVideo({
163
+ inputPath: triggeringKey,
164
+ outputPath: cleanedStagingKey,
165
+ posterPath: posterStagingKey,
166
+ maxDurationSeconds: deps.config.maxDurationSeconds,
167
+ });
168
+ const cleanedStagingKeyOut = transcodeResult.cleanedPath;
169
+ const cleanedBytes = await deps.storage.getObject(cleanedStagingKeyOut);
170
+ // --- 5. Hash the CLEANED bytes ⇒ real content identity; persist it. ---
171
+ // We do NOT write the cleaned bytes to cas/ here: they already live at the
172
+ // STAGING key, and cas/ (the CDN-served prefix) must only ever hold APPROVED
173
+ // bytes. We persist the real hash + future serve key so the completion
174
+ // worker can promote staging -> cas/ on approval.
175
+ const contentHash = createHash("sha256").update(cleanedBytes).digest("hex");
176
+ const cleanedCasKey = casKey(rowTenant, contentHash);
177
+ if (isCasKeyError(cleanedCasKey)) {
178
+ // The hash/tenant failed the CAS allowlist — a permanent defect in our own
179
+ // derivation inputs (e.g. a malformed tenant that slipped the row check).
180
+ // Fail closed: route to review rather than serve un-addressable bytes.
181
+ throw new KeyTenantMismatchError();
182
+ }
183
+ // Replace the upload-time uploadId placeholder contentHash with the REAL
184
+ // hash and record the future serve key (cas/{tenant}/{hash}).
185
+ await deps.persistence.persistCleanedContent(row.id, {
186
+ contentHash,
187
+ originalKey: cleanedCasKey,
188
+ });
189
+ // --- 6. START moderation on the CLEANED STAGING object (the exact bytes ---
190
+ // that will be served), NOT the raw pending upload and NOT a cas/ key.
191
+ const stagingRef = { bucket: deps.bucket, key: cleanedStagingKeyOut };
192
+ const visual = await deps.moderation.startVideoModeration(stagingRef);
193
+ await deps.persistence.createModerationJob({
194
+ mediaId: row.id,
195
+ track: "VISUAL",
196
+ jobId: visual.jobId,
197
+ // Snapshot the CURRENT operative thresholds onto the job at submission.
198
+ thresholdSnapshot: deps.config.thresholds,
199
+ });
200
+ const audio = await deps.transcribe.startTranscription({
201
+ key: cleanedStagingKeyOut,
202
+ jobName: deps.newJobName(cleanedStagingKeyOut),
203
+ });
204
+ await deps.persistence.createModerationJob({
205
+ mediaId: row.id,
206
+ track: "AUDIO",
207
+ jobId: audio.jobId,
208
+ thresholdSnapshot: deps.config.thresholds,
209
+ });
210
+ deps.logger.info("Started per-track moderation jobs", {
211
+ mediaId: row.id,
212
+ stagingKey: cleanedStagingKeyOut,
213
+ casKey: cleanedCasKey,
214
+ visualJobId: visual.jobId,
215
+ audioJobId: audio.jobId,
216
+ });
217
+ return { disposition: "ack", reason: "started-moderation" };
218
+ }
219
+ catch (err) {
220
+ // Single classification point: poison ⇒ REVIEW + ack; retryable ⇒ fail.
221
+ const klass = classifyWorkerError(err);
222
+ if (klass === "poison") {
223
+ // Best-effort route to REVIEW. If we can identify the row, mark it; if we
224
+ // cannot (e.g. the failure was the row lookup itself), there is nothing to
225
+ // mark and the ack simply drops a message that would loop forever.
226
+ const reviewReason = await routePoisonToReview(triggeringKey, deps, err);
227
+ return { disposition: "ack", reason: reviewReason, poison: true };
228
+ }
229
+ deps.logger.error("Retryable media-processing fault — letting SQS retry", {
230
+ key: triggeringKey,
231
+ error: err,
232
+ });
233
+ return { disposition: "fail", reason: "retryable" };
234
+ }
235
+ }
236
+ /**
237
+ * Best-effort: drive the owning MediaFile to REVIEW for a poison failure. Never
238
+ * throws — a failure to mark must not convert a poison ack into an infinite
239
+ * retry. Returns an observability reason string.
240
+ */
241
+ async function routePoisonToReview(triggeringKey, deps, cause) {
242
+ deps.logger.warn("Poison media — routing to REVIEW + ack", {
243
+ key: triggeringKey,
244
+ error: cause,
245
+ });
246
+ const parsed = parsePendingKey(triggeringKey);
247
+ if (parsed === null) {
248
+ return "poison-no-row";
249
+ }
250
+ try {
251
+ const row = await deps.persistence.findMediaByUploadId(parsed.uploadId);
252
+ if (row === null) {
253
+ return "poison-no-row";
254
+ }
255
+ await deps.persistence.markMediaForReview(row.id);
256
+ return "poison-review";
257
+ }
258
+ catch (markErr) {
259
+ deps.logger.error("Failed to mark poison media for REVIEW (acking anyway)", {
260
+ key: triggeringKey,
261
+ error: markErr,
262
+ });
263
+ return "poison-mark-failed";
264
+ }
265
+ }
266
+ /**
267
+ * Process one SQS record (which may carry several S3 object keys). The record
268
+ * fails (SQS retry) iff ANY of its keys produced a retryable fault; otherwise
269
+ * it is acked. Per-key poison is acked, never failed.
270
+ */
271
+ export async function processRecord(record, deps) {
272
+ let keys;
273
+ try {
274
+ keys = extractObjectKeys(record.body);
275
+ }
276
+ catch (err) {
277
+ // A body we cannot even parse is a permanent payload defect (poison): a
278
+ // retry re-parses the same bytes to the same failure. Ack to avoid a loop.
279
+ deps.logger.warn("Unparseable SQS record body — acking as poison", {
280
+ messageId: record.messageId,
281
+ error: err,
282
+ });
283
+ return { disposition: "ack", reason: "unparseable-body", poison: true };
284
+ }
285
+ for (const key of keys) {
286
+ const outcome = await processObjectKey(key, deps);
287
+ if (outcome.disposition === "fail") {
288
+ // First retryable key fails the whole record; SQS redelivers it. Already-
289
+ // started keys are idempotent on the dedupe path (deriveDedupeKey).
290
+ return outcome;
291
+ }
292
+ }
293
+ return { disposition: "ack", reason: "record-complete" };
294
+ }
295
+ // ---------------------------------------------------------------------------
296
+ // Deps injection seam (consuming app wires concrete adapters at startup)
297
+ // ---------------------------------------------------------------------------
298
+ let injectedDeps;
299
+ /**
300
+ * Inject the concrete media-processing seams. The consuming app (Skybber) calls
301
+ * this once at Lambda cold start with its ffmpeg/MediaConvert TranscodePort, S3
302
+ * StoragePort, Transcribe TranscribePort, injected MediaModerationProvider, and
303
+ * a Prisma-backed MediaPersistencePort. Core ships NO concrete adapters.
304
+ */
305
+ export function setMediaProcessingDeps(deps) {
306
+ injectedDeps = deps;
307
+ }
308
+ /** Test helper: clear injected deps between cases. */
309
+ export function __resetMediaProcessingDeps() {
310
+ injectedDeps = undefined;
311
+ }
3
312
  const logger = new Logger({ serviceName: "media-processing-worker" });
4
- const s3 = new S3Client({ region: process.env.AWS_REGION });
313
+ /**
314
+ * The SQS entry point. Preserves `reportBatchItemFailures` semantics: only the
315
+ * messageIds whose records produced a retryable fault are returned as batch
316
+ * item failures; everything else (success / drop / poison→REVIEW) is acked by
317
+ * omission.
318
+ *
319
+ * If no concrete deps were injected, the handler fails CLOSED: it throws, so the
320
+ * whole batch is retried rather than silently dropped. An un-wired worker must
321
+ * never ack-drop real uploads.
322
+ */
5
323
  export const handler = async (event) => {
6
- const failedIds = [];
324
+ if (injectedDeps === undefined) {
325
+ // Fail closed: no backend wired ⇒ retry the batch, never drop. The
326
+ // consuming app must call setMediaProcessingDeps() at startup.
327
+ logger.error("media-processing-worker invoked with no injected deps — refusing to" +
328
+ " process. Call setMediaProcessingDeps() at cold start.");
329
+ throw new Error("media-processing-worker: deps not injected");
330
+ }
331
+ const deps = injectedDeps;
332
+ const batchItemFailures = [];
7
333
  for (const record of event.Records) {
334
+ let outcome;
8
335
  try {
9
- // S3 event notification comes via SQS
10
- const s3Event = JSON.parse(record.body);
11
- const s3Records = s3Event.Records || [];
12
- for (const s3Record of s3Records) {
13
- const bucket = s3Record.s3.bucket.name;
14
- const key = decodeURIComponent(s3Record.s3.object.key.replace(/\+/g, " "));
15
- if (!key.startsWith("originals/"))
16
- continue;
17
- // Get original
18
- const original = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
19
- const chunks = [];
20
- for await (const chunk of original.Body) {
21
- chunks.push(chunk);
22
- }
23
- const buffer = Buffer.concat(chunks);
24
- // Process with Sharp (must be installed as ARM64 binary)
25
- // dynamic import to avoid bundling issues
26
- const sharp = (await import("sharp")).default;
27
- const hash = key.split("/").pop().replace(/\.[^.]+$/, "");
28
- // Thumbnail: 300px WebP
29
- const thumbnail = await sharp(buffer)
30
- .resize(300, 300, { fit: "cover" })
31
- .webp({ quality: 80 })
32
- .toBuffer();
33
- // Optimized: 1200px WebP
34
- const optimized = await sharp(buffer)
35
- .resize(1200, 1200, { fit: "inside", withoutEnlargement: true })
36
- .webp({ quality: 85 })
37
- .toBuffer();
38
- await Promise.all([
39
- s3.send(new PutObjectCommand({
40
- Bucket: bucket, Key: `thumbnails/${hash}.webp`,
41
- Body: thumbnail, ContentType: "image/webp",
42
- })),
43
- s3.send(new PutObjectCommand({
44
- Bucket: bucket, Key: `optimized/${hash}.webp`,
45
- Body: optimized, ContentType: "image/webp",
46
- })),
47
- ]);
48
- logger.info("Media processed", { key, hash });
49
- }
336
+ outcome = await processRecord(record, deps);
50
337
  }
51
338
  catch (err) {
52
- logger.error("Media processing failed", { error: err, messageId: record.messageId });
53
- failedIds.push(record.messageId);
339
+ // Defensive: processRecord is designed not to throw, but if it does, treat
340
+ // it as retryable (fail closed for retry; DLQ + alert is the backstop).
341
+ logger.error("Unexpected throw from processRecord — retrying record", {
342
+ messageId: record.messageId,
343
+ error: err,
344
+ });
345
+ batchItemFailures.push({ itemIdentifier: record.messageId });
346
+ continue;
347
+ }
348
+ if (outcome.disposition === "fail") {
349
+ batchItemFailures.push({ itemIdentifier: record.messageId });
54
350
  }
55
351
  }
56
- if (failedIds.length > 0) {
57
- return { batchItemFailures: failedIds.map((id) => ({ itemIdentifier: id })) };
58
- }
352
+ return { batchItemFailures };
59
353
  };
60
354
  //# sourceMappingURL=media-processing-worker.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"media-processing-worker.js","sourceRoot":"","sources":["../../src/lambda/media-processing-worker.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"media-processing-worker.js","sourceRoot":"","sources":["../../src/lambda/media-processing-worker.ts"],"names":[],"mappings":"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"}
@@ -1,18 +1,42 @@
1
1
  /**
2
- * EXIF Stripping Utility
2
+ * EXIF strip verification helper (T6).
3
3
  *
4
- * PREPARATORY CHANGE: This utility is created now but not yet used in production.
5
- * It will be enabled when implementing location safety features for at-risk users.
4
+ * The byte-level strip is a CONSEQUENCE of T7's re-encode: sharp re-encoding
5
+ * without `.withMetadata()` drops all embedded metadata (EXIF, GPS, ICC,
6
+ * maker-notes). This module provides the POST-encode assertion that the strip
7
+ * actually happened — used defensively at runtime and as the verification
8
+ * contract in tests.
6
9
  *
7
- * Removes EXIF data from images for privacy.
8
- * Can be enabled/disabled via configuration.
10
+ * The old placeholder `stripEXIF()` is preserved below with a narrowed
11
+ * signature so existing callers compile; it delegates to the re-encode and is
12
+ * deprecated. New code must call `assertNoExif` after `reencodeImage`.
9
13
  *
10
- * FUTURE USE:
11
- * - When EXIF_STRIPPING_ENABLED=true, this utility will strip EXIF data from
12
- * uploaded images before storing them
13
- * - Prevents location data, device info, and timestamps from being embedded in images
14
- * - Helps protect user privacy, especially for at-risk users
14
+ * GPS coordinates are NOT persisted: `gpsLatitude`/`gpsLongitude` columns
15
+ * were removed in T8's schema migration. Nothing in this file or the upload
16
+ * handler writes those fields.
15
17
  */
18
+ /**
19
+ * Parse the EXIF/IPTC/GPS/ICC metadata embedded in `imageBytes` and return
20
+ * it as a flat object. Returns `undefined` when exifr finds nothing.
21
+ *
22
+ * Exported for use in tests (assert the object is empty/undefined after
23
+ * re-encode) and as an optional runtime defensive check.
24
+ */
25
+ export declare function parseMetadata(imageBytes: ArrayBuffer | Buffer | Uint8Array): Promise<Record<string, unknown> | undefined>;
26
+ /**
27
+ * Assert that `imageBytes` contains NO privacy-sensitive embedded metadata
28
+ * (EXIF GPS, ICC, maker-notes, camera info). Benign PNG structural fields
29
+ * (ImageWidth, ColorType, etc.) are excluded from this check since exifr
30
+ * parses them from the PNG format header, not from EXIF APP1 segments.
31
+ *
32
+ * Call this on the OUTPUT of `reencodeImage` to confirm the re-encode dropped
33
+ * all user/device metadata. Useful both in tests and as an optional defensive
34
+ * runtime check.
35
+ *
36
+ * @throws `Error` when privacy-sensitive metadata is present.
37
+ */
38
+ export declare function assertNoExif(imageBytes: ArrayBuffer | Buffer | Uint8Array): Promise<void>;
39
+ /** @deprecated Legacy config interface — no longer has any effect. */
16
40
  export interface EXIFStripperConfig {
17
41
  enabled: boolean;
18
42
  removeLocation: boolean;
@@ -20,18 +44,9 @@ export interface EXIFStripperConfig {
20
44
  removeTimestamp: boolean;
21
45
  }
22
46
  /**
23
- * Strip EXIF data from image buffer
24
- *
25
- * FUTURE USE: This function will be called during media upload processing
26
- * to remove EXIF data from images before storing them in R2.
27
- *
28
- * NOTE: This is currently a placeholder implementation.
29
- * When implementing, use a library like 'piexifjs' or 'exifr' to actually
30
- * remove EXIF data from the image buffer.
31
- *
32
- * @param imageBuffer - Image buffer to process
33
- * @param config - Stripping configuration
34
- * @returns Processed image buffer (with EXIF removed if enabled)
47
+ * @deprecated The strip now happens as a side-effect of `reencodeImage`
48
+ * (T7). This function is a no-op passthrough kept solely so the existing
49
+ * test suite compiles without change. Do not call in new code.
35
50
  */
36
51
  export declare function stripEXIF(imageBuffer: ArrayBuffer, config?: EXIFStripperConfig): Promise<ArrayBuffer>;
37
52
  //# sourceMappingURL=exif-stripper.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"exif-stripper.d.ts","sourceRoot":"","sources":["../../src/lib/exif-stripper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,cAAc,EAAE,OAAO,CAAC;IACxB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,SAAS,CAC7B,WAAW,EAAE,WAAW,EACxB,MAAM,GAAE,kBAKP,GACA,OAAO,CAAC,WAAW,CAAC,CAyBtB"}
1
+ {"version":3,"file":"exif-stripper.d.ts","sourceRoot":"","sources":["../../src/lib/exif-stripper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH;;;;;;GAMG;AACH,wBAAsB,aAAa,CACjC,UAAU,EAAE,WAAW,GAAG,MAAM,GAAG,UAAU,GAC5C,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC,CA2B9C;AAiCD;;;;;;;;;;;GAWG;AACH,wBAAsB,YAAY,CAChC,UAAU,EAAE,WAAW,GAAG,MAAM,GAAG,UAAU,GAC5C,OAAO,CAAC,IAAI,CAAC,CAgBf;AAQD,sEAAsE;AACtE,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,cAAc,EAAE,OAAO,CAAC;IACxB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAsB,SAAS,CAC7B,WAAW,EAAE,WAAW,EACxB,MAAM,GAAE,kBAKP,GACA,OAAO,CAAC,WAAW,CAAC,CAKtB"}