@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.
- package/dist/env.d.ts +168 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +155 -0
- package/dist/env.js.map +1 -1
- package/dist/lambda/media-completion-worker.d.ts +175 -0
- package/dist/lambda/media-completion-worker.d.ts.map +1 -0
- package/dist/lambda/media-completion-worker.js +373 -0
- package/dist/lambda/media-completion-worker.js.map +1 -0
- package/dist/lambda/media-processing-worker.d.ts +172 -1
- package/dist/lambda/media-processing-worker.d.ts.map +1 -1
- package/dist/lambda/media-processing-worker.js +343 -49
- package/dist/lambda/media-processing-worker.js.map +1 -1
- package/dist/lib/exif-stripper.d.ts +37 -22
- package/dist/lib/exif-stripper.d.ts.map +1 -1
- package/dist/lib/exif-stripper.js +101 -41
- package/dist/lib/exif-stripper.js.map +1 -1
- package/dist/lib/media/cas-keys.d.ts +63 -0
- package/dist/lib/media/cas-keys.d.ts.map +1 -0
- package/dist/lib/media/cas-keys.js +102 -0
- package/dist/lib/media/cas-keys.js.map +1 -0
- package/dist/lib/media/classify-worker-error.d.ts +48 -0
- package/dist/lib/media/classify-worker-error.d.ts.map +1 -0
- package/dist/lib/media/classify-worker-error.js +319 -0
- package/dist/lib/media/classify-worker-error.js.map +1 -0
- package/dist/lib/media/dedupe-key.d.ts +29 -0
- package/dist/lib/media/dedupe-key.d.ts.map +1 -0
- package/dist/lib/media/dedupe-key.js +49 -0
- package/dist/lib/media/dedupe-key.js.map +1 -0
- package/dist/lib/media/duration-cap.d.ts +30 -0
- package/dist/lib/media/duration-cap.d.ts.map +1 -0
- package/dist/lib/media/duration-cap.js +37 -0
- package/dist/lib/media/duration-cap.js.map +1 -0
- package/dist/lib/media/ffmpeg-args.d.ts +83 -0
- package/dist/lib/media/ffmpeg-args.d.ts.map +1 -0
- package/dist/lib/media/ffmpeg-args.js +119 -0
- package/dist/lib/media/ffmpeg-args.js.map +1 -0
- package/dist/lib/media/media-ports.d.ts +126 -0
- package/dist/lib/media/media-ports.d.ts.map +1 -0
- package/dist/lib/media/media-ports.js +129 -0
- package/dist/lib/media/media-ports.js.map +1 -0
- package/dist/lib/media/media-upsert.d.ts +55 -0
- package/dist/lib/media/media-upsert.d.ts.map +1 -0
- package/dist/lib/media/media-upsert.js +38 -0
- package/dist/lib/media/media-upsert.js.map +1 -0
- package/dist/lib/media/moderation-provider.d.ts +111 -0
- package/dist/lib/media/moderation-provider.d.ts.map +1 -0
- package/dist/lib/media/moderation-provider.js +130 -0
- package/dist/lib/media/moderation-provider.js.map +1 -0
- package/dist/lib/media/moderation-resolved-payload.d.ts +48 -0
- package/dist/lib/media/moderation-resolved-payload.d.ts.map +1 -0
- package/dist/lib/media/moderation-resolved-payload.js +37 -0
- package/dist/lib/media/moderation-resolved-payload.js.map +1 -0
- package/dist/lib/media/moderation-status.d.ts +98 -0
- package/dist/lib/media/moderation-status.d.ts.map +1 -0
- package/dist/lib/media/moderation-status.js +122 -0
- package/dist/lib/media/moderation-status.js.map +1 -0
- package/dist/lib/media/processing-types.d.ts +45 -0
- package/dist/lib/media/processing-types.d.ts.map +1 -0
- package/dist/lib/media/processing-types.js +9 -0
- package/dist/lib/media/processing-types.js.map +1 -0
- package/dist/lib/media/promote-decision.d.ts +64 -0
- package/dist/lib/media/promote-decision.d.ts.map +1 -0
- package/dist/lib/media/promote-decision.js +76 -0
- package/dist/lib/media/promote-decision.js.map +1 -0
- package/dist/lib/media/quota-check.d.ts +22 -0
- package/dist/lib/media/quota-check.d.ts.map +1 -0
- package/dist/lib/media/quota-check.js +42 -0
- package/dist/lib/media/quota-check.js.map +1 -0
- package/dist/lib/media/quota-types.d.ts +15 -0
- package/dist/lib/media/quota-types.d.ts.map +1 -0
- package/dist/lib/media/quota-types.js +9 -0
- package/dist/lib/media/quota-types.js.map +1 -0
- package/dist/lib/media/route-upload.d.ts +58 -0
- package/dist/lib/media/route-upload.d.ts.map +1 -0
- package/dist/lib/media/route-upload.js +80 -0
- package/dist/lib/media/route-upload.js.map +1 -0
- package/dist/lib/media/serve-gate.d.ts +51 -0
- package/dist/lib/media/serve-gate.d.ts.map +1 -0
- package/dist/lib/media/serve-gate.js +68 -0
- package/dist/lib/media/serve-gate.js.map +1 -0
- package/dist/lib/media/tenant-resolution.d.ts +42 -0
- package/dist/lib/media/tenant-resolution.d.ts.map +1 -0
- package/dist/lib/media/tenant-resolution.js +45 -0
- package/dist/lib/media/tenant-resolution.js.map +1 -0
- package/dist/lib/media/text-moderation.d.ts +28 -0
- package/dist/lib/media/text-moderation.d.ts.map +1 -0
- package/dist/lib/media/text-moderation.js +62 -0
- package/dist/lib/media/text-moderation.js.map +1 -0
- package/dist/lib/media/track-verdict.d.ts +45 -0
- package/dist/lib/media/track-verdict.d.ts.map +1 -0
- package/dist/lib/media/track-verdict.js +52 -0
- package/dist/lib/media/track-verdict.js.map +1 -0
- package/dist/lib/media/transcript-moderation.d.ts +47 -0
- package/dist/lib/media/transcript-moderation.d.ts.map +1 -0
- package/dist/lib/media/transcript-moderation.js +70 -0
- package/dist/lib/media/transcript-moderation.js.map +1 -0
- package/dist/lib/media-handler.d.ts.map +1 -1
- package/dist/lib/media-handler.js +15 -9
- package/dist/lib/media-handler.js.map +1 -1
- package/dist/lib/post-handler.d.ts.map +1 -1
- package/dist/lib/post-handler.js +4 -1
- package/dist/lib/post-handler.js.map +1 -1
- package/dist/lib/routes/media.d.ts +21 -0
- package/dist/lib/routes/media.d.ts.map +1 -1
- package/dist/lib/routes/media.js +584 -483
- package/dist/lib/routes/media.js.map +1 -1
- package/dist/lib/services/image-normalizer.d.ts +64 -6
- package/dist/lib/services/image-normalizer.d.ts.map +1 -1
- package/dist/lib/services/image-normalizer.js +88 -6
- package/dist/lib/services/image-normalizer.js.map +1 -1
- package/dist/lib/services/media-upload-service.d.ts +2 -2
- package/dist/lib/services/media-upload-service.d.ts.map +1 -1
- package/dist/lib/services/media-upload-service.js +22 -21
- package/dist/lib/services/media-upload-service.js.map +1 -1
- package/dist/lib/tenant-scope.d.ts.map +1 -1
- package/dist/lib/tenant-scope.js +16 -1
- package/dist/lib/tenant-scope.js.map +1 -1
- package/package.json +2 -1
- package/prisma/migrations/20260625000000_media_tenant_scope_and_moderation_status/migration.sql +49 -0
- package/prisma/migrations/20260625000001_p0b_moderation_jobs/migration.sql +73 -0
- package/prisma/schema.prisma +95 -17
- package/src/lambda/media-completion-worker.ts +567 -0
- package/src/lambda/media-processing-worker.ts +508 -59
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// media-completion-worker.ts — imperative SHELL for the standardized media
|
|
2
|
+
// job-completion SQS queue (B2).
|
|
3
|
+
//
|
|
4
|
+
// One queue drains the completion notifications of BOTH async moderation
|
|
5
|
+
// tracks of a media object:
|
|
6
|
+
//
|
|
7
|
+
// - VISUAL: the image/video moderation provider finishes a job and publishes
|
|
8
|
+
// a completion to SNS, which is fanned into this SQS queue. The SNS envelope
|
|
9
|
+
// carries `{ Message: "<json>" }` whose inner JSON has a `JobId`.
|
|
10
|
+
// - AUDIO: speech-to-text (Transcribe) finishes and emits an EventBridge event
|
|
11
|
+
// fanned into this SQS queue, whose `detail` carries `TranscriptionJobName`.
|
|
12
|
+
//
|
|
13
|
+
// THREAT MODEL: the SQS message body is an UNTRUSTED POINTER. A replay, a forged
|
|
14
|
+
// body, or a spoofed verdict must not move a media object toward "approved". So
|
|
15
|
+
// the worker treats the body as nothing more than a job-id pointer and re-fetches
|
|
16
|
+
// the authoritative state from the provider. The body's own verdict/status (if
|
|
17
|
+
// any) is ALWAYS ignored.
|
|
18
|
+
//
|
|
19
|
+
// SAFETY ORDER (fixed, never reordered):
|
|
20
|
+
// 0. parse body -> jobId ONLY
|
|
21
|
+
// 1. DEDUPE FIRST: insert ProcessedModerationMessage(dedupeKey); if it already
|
|
22
|
+
// exists, ack-drop (idempotent no-op) BEFORE any side effect.
|
|
23
|
+
// 2. RE-FETCH authoritative track state from the provider (visual) or the
|
|
24
|
+
// transcription seam (audio). Derive THIS track's decision SOLELY from the
|
|
25
|
+
// re-fetched result. Non-terminal / failed / unknown => errored (fail closed).
|
|
26
|
+
// 3. Look up the MediaModerationJob by jobId => mediaId + track. Persist this
|
|
27
|
+
// track's decision. Read the OTHER track's decision. Build both TrackOutcomes.
|
|
28
|
+
// 4. decidePromotion({ visual, audio, currentStatus, casObjectPresent }).
|
|
29
|
+
// casObjectPresent = the cleaned bytes are available to serve: TRUE iff the
|
|
30
|
+
// cas/ key exists (a prior promote) OR the cleaned STAGING key exists (the
|
|
31
|
+
// processing worker left them there, pre-promote).
|
|
32
|
+
// 5. APPLY in fixed order:
|
|
33
|
+
// a. if shouldPromote: copyObject(STAGING -> cas) — promote the CLEANED
|
|
34
|
+
// STAGING bytes (the exact bytes that were moderated), NEVER the raw
|
|
35
|
+
// pending upload. Then best-effort deleteObject(pending) (raw-original
|
|
36
|
+
// cleanup) AND deleteObject(staging) (staging cleanup) — tolerate
|
|
37
|
+
// already-deleted on both. cas/ thus only ever holds APPROVED cleaned
|
|
38
|
+
// bytes ("cleaned-staging, promote-on-approval").
|
|
39
|
+
// b. if shouldPersistStatus: persist transition.status.
|
|
40
|
+
// c. if shouldEmitResolved: emit moderation.resolved with
|
|
41
|
+
// moderationResolvedPayload(mediaId, status) — ready|not-ready ONLY.
|
|
42
|
+
// An illegal transition (transition.ok === false) is ack-dropped, never DLQ.
|
|
43
|
+
//
|
|
44
|
+
// THRESHOLD SNAPSHOT: when (re)interpreting the re-fetched verdict we use the
|
|
45
|
+
// threshold snapshot stored ON THE JOB ROW (job.thresholdSnapshot), never live
|
|
46
|
+
// Env — so a config edit landing between the original submission and a (re)delivery
|
|
47
|
+
// cannot flip a replayed verdict. The reinterpreter is an injected pure function
|
|
48
|
+
// (no operational numbers live in this PUBLIC tarball).
|
|
49
|
+
//
|
|
50
|
+
// This is the imperative shell: it sequences I/O and delegates EVERY decision to
|
|
51
|
+
// the pure functional-core units (decidePromotion, combineTrackVerdicts,
|
|
52
|
+
// transcriptToModerationDecision, deriveDedupeKey, moderationResolvedPayload).
|
|
53
|
+
import { decidePromotion } from "../lib/media/promote-decision.js";
|
|
54
|
+
import { casKey, pendingKey, isCasKeyError } from "../lib/media/cas-keys.js";
|
|
55
|
+
import { deriveDedupeKey } from "../lib/media/dedupe-key.js";
|
|
56
|
+
import { moderationResolvedPayload } from "../lib/media/moderation-resolved-payload.js";
|
|
57
|
+
import { transcriptToModerationDecision } from "../lib/media/transcript-moderation.js";
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Body parsing — extract ONLY the job id from an untrusted pointer.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
/**
|
|
62
|
+
* Extract the provider job id from an untrusted completion message body.
|
|
63
|
+
*
|
|
64
|
+
* Two shapes are accepted; everything else (and any embedded verdict/status) is
|
|
65
|
+
* ignored:
|
|
66
|
+
* - Rekognition via SNS: { "Message": "{...\"JobId\":\"...\"}" } OR a body
|
|
67
|
+
* that itself directly carries { "JobId": "..." }.
|
|
68
|
+
* - Transcribe via EventBridge: { "detail": { "TranscriptionJobName": "..." } }
|
|
69
|
+
* OR a body that directly carries { "TranscriptionJobName": "..." }.
|
|
70
|
+
*
|
|
71
|
+
* Returns the job id and which track it belongs to, or `null` when no job id can
|
|
72
|
+
* be recovered (fail-closed: the caller ack-drops an unroutable message rather
|
|
73
|
+
* than DLQ-looping a permanently-malformed pointer).
|
|
74
|
+
*
|
|
75
|
+
* Pure & total: never throws.
|
|
76
|
+
*/
|
|
77
|
+
export function extractJobPointer(body) {
|
|
78
|
+
let root;
|
|
79
|
+
try {
|
|
80
|
+
root = JSON.parse(body);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (root === null || typeof root !== "object")
|
|
86
|
+
return null;
|
|
87
|
+
const obj = root;
|
|
88
|
+
// Transcribe (AUDIO): EventBridge `detail.TranscriptionJobName`, or direct.
|
|
89
|
+
const detail = typeof obj.detail === "object" && obj.detail !== null
|
|
90
|
+
? obj.detail
|
|
91
|
+
: undefined;
|
|
92
|
+
const transcriptionName = pickString(detail?.TranscriptionJobName) ??
|
|
93
|
+
pickString(obj.TranscriptionJobName);
|
|
94
|
+
if (transcriptionName !== null) {
|
|
95
|
+
return { jobId: transcriptionName, track: "AUDIO" };
|
|
96
|
+
}
|
|
97
|
+
// Rekognition (VISUAL): SNS `Message` is a JSON string carrying `JobId`, or
|
|
98
|
+
// the body carries `JobId` directly.
|
|
99
|
+
const directJobId = pickString(obj.JobId);
|
|
100
|
+
if (directJobId !== null) {
|
|
101
|
+
return { jobId: directJobId, track: "VISUAL" };
|
|
102
|
+
}
|
|
103
|
+
const snsMessage = pickString(obj.Message);
|
|
104
|
+
if (snsMessage !== null) {
|
|
105
|
+
let inner;
|
|
106
|
+
try {
|
|
107
|
+
inner = JSON.parse(snsMessage);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (inner !== null && typeof inner === "object") {
|
|
113
|
+
const innerJobId = pickString(inner.JobId);
|
|
114
|
+
if (innerJobId !== null) {
|
|
115
|
+
return { jobId: innerJobId, track: "VISUAL" };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
/** Return a non-empty string value, or null for anything else. */
|
|
122
|
+
function pickString(v) {
|
|
123
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
124
|
+
}
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Re-fetch authoritative track decision (fail-closed).
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
/**
|
|
129
|
+
* Re-fetch THIS track's authoritative decision from the provider, ignoring the
|
|
130
|
+
* message body entirely. Fail-closed: a non-terminal / failed / unknown result
|
|
131
|
+
* yields `"errored"`-equivalent `null` so the caller records it as an errored
|
|
132
|
+
* track outcome (never `"approved"`).
|
|
133
|
+
*
|
|
134
|
+
* VISUAL: `getVideoModeration(jobId)` returns a settled verdict; the verdict is
|
|
135
|
+
* re-interpreted against the JOB's threshold snapshot (not live Env).
|
|
136
|
+
*
|
|
137
|
+
* AUDIO: `getTranscription(jobId)` is polled; only a COMPLETED transcription is
|
|
138
|
+
* fed to `transcriptToModerationDecision` (which is itself fail-closed). Any
|
|
139
|
+
* other status (IN_PROGRESS / FAILED) yields `null` (errored — fail closed).
|
|
140
|
+
*/
|
|
141
|
+
export async function refetchTrackDecision(pointer, job, deps) {
|
|
142
|
+
if (pointer.track === "VISUAL") {
|
|
143
|
+
const verdict = await deps.moderation.getVideoModeration(pointer.jobId);
|
|
144
|
+
if (verdict == null || typeof verdict !== "object")
|
|
145
|
+
return null;
|
|
146
|
+
const decision = deps.reinterpretVisual(verdict, job.thresholdSnapshot);
|
|
147
|
+
return normalizeDecision(decision);
|
|
148
|
+
}
|
|
149
|
+
// AUDIO
|
|
150
|
+
const res = await deps.transcribe.getTranscription(pointer.jobId);
|
|
151
|
+
if (res == null || res.status !== "COMPLETED") {
|
|
152
|
+
// Non-terminal or failed transcription — fail closed (errored).
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const transcript = res.transcript ?? "";
|
|
156
|
+
const decision = await transcriptToModerationDecision(transcript, deps.textModeration);
|
|
157
|
+
return normalizeDecision(decision);
|
|
158
|
+
}
|
|
159
|
+
/** Accept only the three known decisions; anything else fails closed to null. */
|
|
160
|
+
function normalizeDecision(d) {
|
|
161
|
+
return d === "approved" || d === "review" || d === "quarantine" ? d : null;
|
|
162
|
+
}
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Build a TrackOutcome from a (possibly null) decision.
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
/** A resolved decision => `decided`; a null (failed/unknown) => `errored`. */
|
|
167
|
+
function outcomeFromDecision(d) {
|
|
168
|
+
return d === null ? { state: "errored" } : { state: "decided", decision: d };
|
|
169
|
+
}
|
|
170
|
+
/** Map the sibling track's persisted state to a TrackOutcome (fail-closed). */
|
|
171
|
+
function outcomeFromOther(other) {
|
|
172
|
+
switch (other.state) {
|
|
173
|
+
case "decided":
|
|
174
|
+
return { state: "decided", decision: other.decision };
|
|
175
|
+
case "absent":
|
|
176
|
+
return { state: "absent" };
|
|
177
|
+
case "pending":
|
|
178
|
+
default:
|
|
179
|
+
// A sibling job that exists but has not resolved is NOT approval — the
|
|
180
|
+
// combinator treats `absent` and `errored` alike (both degrade away from
|
|
181
|
+
// approved), and we have no positive evidence yet, so fail closed.
|
|
182
|
+
return { state: "errored" };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Per-record processor (the heart; mock-friendly).
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
/**
|
|
189
|
+
* Process one completion message. Returns a {@link RecordOutcome}; only
|
|
190
|
+
* `kind: "retry"` should be surfaced to SQS as a batch-item failure. Throws are
|
|
191
|
+
* caught by the handler and converted to a retry.
|
|
192
|
+
*/
|
|
193
|
+
export async function processCompletion(body, deps) {
|
|
194
|
+
// 0. Extract ONLY the job id from the untrusted pointer.
|
|
195
|
+
const pointer = extractJobPointer(body);
|
|
196
|
+
if (pointer === null) {
|
|
197
|
+
deps.log?.warn?.("completion: no job pointer in body — dropping");
|
|
198
|
+
return { kind: "unroutable" };
|
|
199
|
+
}
|
|
200
|
+
// 1. Look up the job row first — needed for the contentHash that scopes the
|
|
201
|
+
// dedupe key and for the threshold snapshot. (No side effect yet.)
|
|
202
|
+
const job = await deps.store.findJobByJobId(pointer.jobId);
|
|
203
|
+
if (job === null) {
|
|
204
|
+
deps.log?.warn?.("completion: unknown jobId — dropping", {
|
|
205
|
+
jobId: pointer.jobId,
|
|
206
|
+
});
|
|
207
|
+
return { kind: "unroutable" };
|
|
208
|
+
}
|
|
209
|
+
// The media object's content hash addresses the dedupe key so identical bytes
|
|
210
|
+
// share fan-in across tenants; we read it from the media coords below. But the
|
|
211
|
+
// dedupe MUST happen before ANY side effect, and persistTrackDecision is a
|
|
212
|
+
// side effect — so we resolve the media first (read-only) to obtain the hash.
|
|
213
|
+
const media = await deps.store.findMedia(job.mediaId);
|
|
214
|
+
if (media === null) {
|
|
215
|
+
deps.log?.warn?.("completion: media row missing — dropping", {
|
|
216
|
+
mediaId: job.mediaId,
|
|
217
|
+
});
|
|
218
|
+
return { kind: "unroutable" };
|
|
219
|
+
}
|
|
220
|
+
// 2. DEDUPE FIRST — before any side effect. The dedupe key binds the content
|
|
221
|
+
// hash, the jobId, and the track so the two tracks of the same bytes never
|
|
222
|
+
// collide and a redelivery of the SAME completion is a no-op.
|
|
223
|
+
const dedupeKey = deriveDedupeKey({
|
|
224
|
+
contentHash: media.contentHash,
|
|
225
|
+
jobId: pointer.jobId,
|
|
226
|
+
track: pointer.track,
|
|
227
|
+
});
|
|
228
|
+
const claimed = await deps.store.claimMessage(dedupeKey);
|
|
229
|
+
if (!claimed) {
|
|
230
|
+
deps.log?.info?.("completion: duplicate delivery — ack-drop", {
|
|
231
|
+
jobId: pointer.jobId,
|
|
232
|
+
});
|
|
233
|
+
return { kind: "duplicate" };
|
|
234
|
+
}
|
|
235
|
+
// 3. RE-FETCH authoritative state for THIS track (body verdict ignored).
|
|
236
|
+
const thisDecision = await refetchTrackDecision(pointer, job, deps);
|
|
237
|
+
// Persist this track's decision (the side effect we just earned the right to
|
|
238
|
+
// perform). An errored re-fetch persists nothing on the row (decision stays
|
|
239
|
+
// null) but still contributes an `errored` outcome to the combine.
|
|
240
|
+
if (thisDecision !== null) {
|
|
241
|
+
await deps.store.persistTrackDecision(pointer.jobId, thisDecision);
|
|
242
|
+
}
|
|
243
|
+
// Read the OTHER track's decision and build both outcomes.
|
|
244
|
+
const other = await deps.store.readOtherTrack(job.mediaId, pointer.track);
|
|
245
|
+
const thisOutcome = outcomeFromDecision(thisDecision);
|
|
246
|
+
const otherOutcome = outcomeFromOther(other);
|
|
247
|
+
const visual = pointer.track === "VISUAL" ? thisOutcome : otherOutcome;
|
|
248
|
+
const audio = pointer.track === "AUDIO" ? thisOutcome : otherOutcome;
|
|
249
|
+
// Derive every storage key from the row's identity columns via the canonical
|
|
250
|
+
// cas-keys builders — key construction is centralized here, never trusted from
|
|
251
|
+
// the store. A malformed identity (should be impossible past the upload gate)
|
|
252
|
+
// fails closed to a retry rather than touching an un-addressable key.
|
|
253
|
+
const casK = casKey(media.tenantId, media.contentHash);
|
|
254
|
+
const pendingK = pendingKey(media.tenantId, media.uploadId);
|
|
255
|
+
if (isCasKeyError(casK) || isCasKeyError(pendingK)) {
|
|
256
|
+
deps.log?.error?.("completion: un-addressable media identity — retry", {
|
|
257
|
+
mediaId: job.mediaId,
|
|
258
|
+
});
|
|
259
|
+
return { kind: "retry", reason: "cas-key-error" };
|
|
260
|
+
}
|
|
261
|
+
// The cleaned bytes live at the STAGING key until promoted to cas/.
|
|
262
|
+
const stagingK = `processing/${media.tenantId}/${media.uploadId}`;
|
|
263
|
+
// 4. Decide (pure). Promotion is gated on the cleaned bytes being available to
|
|
264
|
+
// serve: present in cas/ (after a prior promote) OR at the staging key
|
|
265
|
+
// (before promote). Either satisfies casObjectPresent.
|
|
266
|
+
const casPresent = (await deps.storage.headObject(casK)).exists ||
|
|
267
|
+
(await deps.storage.headObject(stagingK)).exists;
|
|
268
|
+
const action = decidePromotion({
|
|
269
|
+
visual,
|
|
270
|
+
audio,
|
|
271
|
+
currentStatus: media.moderationStatus,
|
|
272
|
+
casObjectPresent: casPresent,
|
|
273
|
+
});
|
|
274
|
+
// An illegal transition (e.g. replay on a terminal APPROVED/REJECTED) is an
|
|
275
|
+
// idempotent ack-drop — NEVER a DLQ.
|
|
276
|
+
if (action.transition.ok === false) {
|
|
277
|
+
deps.log?.info?.("completion: illegal/absorbing transition — ack-drop", {
|
|
278
|
+
mediaId: job.mediaId,
|
|
279
|
+
from: media.moderationStatus,
|
|
280
|
+
});
|
|
281
|
+
return { kind: "illegal-transition" };
|
|
282
|
+
}
|
|
283
|
+
const nextStatusValue = action.transition.status;
|
|
284
|
+
// 5. APPLY in fixed order: promote -> persist -> emit.
|
|
285
|
+
// 5a. PROMOTE: copy the CLEANED STAGING bytes (the exact bytes that were
|
|
286
|
+
// moderated) to cas/ so they can serve — NEVER the raw pending upload.
|
|
287
|
+
// copyObject is idempotent (content-derived target key). Then best-effort
|
|
288
|
+
// remove BOTH the raw original (pending/) and the staging copy. cas/ thus
|
|
289
|
+
// only ever holds APPROVED cleaned bytes.
|
|
290
|
+
if (action.shouldPromote) {
|
|
291
|
+
await deps.storage.copyObject(stagingK, casK);
|
|
292
|
+
// Best-effort raw-original cleanup. Tolerate already-deleted (a prior
|
|
293
|
+
// delivery or lifecycle expiry) — the cas/ copy is what matters.
|
|
294
|
+
try {
|
|
295
|
+
await deps.storage.deleteObject(pendingK);
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
deps.log?.warn?.("completion: pending delete tolerated", {
|
|
299
|
+
mediaId: job.mediaId,
|
|
300
|
+
error: String(err),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
// Best-effort staging cleanup. Same tolerance.
|
|
304
|
+
try {
|
|
305
|
+
await deps.storage.deleteObject(stagingK);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
deps.log?.warn?.("completion: staging delete tolerated", {
|
|
309
|
+
mediaId: job.mediaId,
|
|
310
|
+
error: String(err),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// 5b. PERSIST the new status.
|
|
315
|
+
if (action.shouldPersistStatus) {
|
|
316
|
+
await deps.store.persistMediaStatus(job.mediaId, nextStatusValue);
|
|
317
|
+
}
|
|
318
|
+
// 5c. EMIT the anti-oracle resolved event (ready|not-ready ONLY).
|
|
319
|
+
if (action.shouldEmitResolved) {
|
|
320
|
+
const payload = moderationResolvedPayload(job.mediaId, nextStatusValue);
|
|
321
|
+
await deps.emitResolved(payload);
|
|
322
|
+
}
|
|
323
|
+
deps.log?.info?.("completion: applied", {
|
|
324
|
+
mediaId: job.mediaId,
|
|
325
|
+
status: nextStatusValue,
|
|
326
|
+
});
|
|
327
|
+
return { kind: "applied", status: nextStatusValue };
|
|
328
|
+
}
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// SQS handler — the thin adapter. Wiring of concrete adapters (Prisma,
|
|
331
|
+
// Rekognition/Transcribe SDK clients, S3, the event emitter, the threshold-
|
|
332
|
+
// snapshot reinterpreter) is done by the consuming app at startup and bound into
|
|
333
|
+
// `buildDeps`; THIS file ships SDK-free except for the lambda type import.
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
/**
|
|
336
|
+
* Build the SQS handler from injected deps. The consuming app provides the
|
|
337
|
+
* concrete adapters; tests provide mocks and call {@link processCompletion}
|
|
338
|
+
* directly.
|
|
339
|
+
*
|
|
340
|
+
* A record that yields `kind: "retry"` (or throws) is reported as a batch-item
|
|
341
|
+
* failure so SQS retries / DLQs it. Every other outcome is an ack (the message
|
|
342
|
+
* is consumed): duplicates, unroutable pointers, and illegal transitions are all
|
|
343
|
+
* fail-closed ack-drops — they must never DLQ-loop.
|
|
344
|
+
*/
|
|
345
|
+
export function makeHandler(deps) {
|
|
346
|
+
return async (event) => {
|
|
347
|
+
const failedIds = [];
|
|
348
|
+
for (const record of event.Records) {
|
|
349
|
+
try {
|
|
350
|
+
const outcome = await processCompletion(record.body, deps);
|
|
351
|
+
if (outcome.kind === "retry") {
|
|
352
|
+
deps.log?.error?.("completion: retry", {
|
|
353
|
+
messageId: record.messageId,
|
|
354
|
+
reason: outcome.reason,
|
|
355
|
+
});
|
|
356
|
+
failedIds.push(record.messageId);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
// Unexpected (transient I/O) failure — return to the queue for retry.
|
|
361
|
+
deps.log?.error?.("completion: unexpected failure — retry", {
|
|
362
|
+
messageId: record.messageId,
|
|
363
|
+
error: String(err),
|
|
364
|
+
});
|
|
365
|
+
failedIds.push(record.messageId);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (failedIds.length > 0) {
|
|
369
|
+
return { batchItemFailures: failedIds.map((id) => ({ itemIdentifier: id })) };
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
//# sourceMappingURL=media-completion-worker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media-completion-worker.js","sourceRoot":"","sources":["../../src/lambda/media-completion-worker.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,3 +1,174 @@
|
|
|
1
|
-
import type { SQSHandler } from "aws-lambda";
|
|
1
|
+
import type { SQSHandler, SQSRecord } from "aws-lambda";
|
|
2
|
+
import { Logger } from "@aws-lambda-powertools/logger";
|
|
3
|
+
import type { Track } from "../lib/media/track-verdict.js";
|
|
4
|
+
import type { StoragePort, TranscodePort, TranscribePort } from "../lib/media/media-ports.js";
|
|
5
|
+
import type { MediaModerationProvider } from "../lib/media/moderation-provider.js";
|
|
6
|
+
/**
|
|
7
|
+
* The minimal MediaFile row shape the worker reads. Re-declared (not imported
|
|
8
|
+
* from the Prisma client) so this module compiles in worktrees that have not
|
|
9
|
+
* regenerated the client, mirroring moderation-status.ts's discipline. The
|
|
10
|
+
* shell maps the real Prisma row to this shape at the persistence-port boundary.
|
|
11
|
+
*/
|
|
12
|
+
export interface MediaFileRow {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly tenantId: string;
|
|
15
|
+
readonly uploadId: string | null;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A copy of the operative moderation thresholds, snapshotted at job-submission
|
|
19
|
+
* time so historical decisions stay auditable after a threshold change. The
|
|
20
|
+
* shape mirrors Env.media.thresholds; the worker treats it as an opaque JSON
|
|
21
|
+
* blob and never reads individual values (no compiled threshold logic here).
|
|
22
|
+
*/
|
|
23
|
+
export type ThresholdSnapshot = Record<string, {
|
|
24
|
+
review: number;
|
|
25
|
+
quarantine: number;
|
|
26
|
+
}>;
|
|
27
|
+
/**
|
|
28
|
+
* The persistence operations the worker needs, narrowed to exactly what it
|
|
29
|
+
* uses. Implemented in production by a thin Prisma adapter; in tests by an
|
|
30
|
+
* in-memory fake. Keeping this narrow keeps the worker testable without the
|
|
31
|
+
* generated client and documents the worker's full DB surface in one place.
|
|
32
|
+
*/
|
|
33
|
+
export interface MediaPersistencePort {
|
|
34
|
+
/** Load the MediaFile row for an upload session, or null if none exists. */
|
|
35
|
+
findMediaByUploadId(uploadId: string): Promise<MediaFileRow | null>;
|
|
36
|
+
/** Persist a started per-track moderation job with its threshold snapshot. */
|
|
37
|
+
createModerationJob(input: {
|
|
38
|
+
mediaId: string;
|
|
39
|
+
track: Track;
|
|
40
|
+
jobId: string;
|
|
41
|
+
thresholdSnapshot: ThresholdSnapshot;
|
|
42
|
+
}): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Persist the REAL content identity of the cleaned bytes onto the MediaFile
|
|
45
|
+
* row, replacing the upload-time `uploadId` placeholder contentHash with the
|
|
46
|
+
* SHA-256 of the transcoded output and recording the future serve key. The
|
|
47
|
+
* completion worker derives the promote target (`cas/{tenant}/{hash}`) from
|
|
48
|
+
* this persisted `contentHash`, so this write MUST happen before moderation
|
|
49
|
+
* fans in — otherwise the object can never promote.
|
|
50
|
+
*/
|
|
51
|
+
persistCleanedContent(mediaId: string, content: {
|
|
52
|
+
contentHash: string;
|
|
53
|
+
originalKey: string;
|
|
54
|
+
}): Promise<void>;
|
|
55
|
+
/** Drive a media object's moderationStatus to REVIEW (poison path). */
|
|
56
|
+
markMediaForReview(mediaId: string): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The slice of Env.media this worker consumes. Operational parameters arrive
|
|
60
|
+
* here as VALUES sourced from Env.media — never as literals in this file.
|
|
61
|
+
*/
|
|
62
|
+
export interface MediaProcessingConfig {
|
|
63
|
+
/** Hard duration cap (seconds). From Env.media.maxDurationSeconds. */
|
|
64
|
+
readonly maxDurationSeconds: number;
|
|
65
|
+
/** Current operative thresholds, snapshotted onto each started job. */
|
|
66
|
+
readonly thresholds: ThresholdSnapshot;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* All capability seams the orchestration core binds to. The handler builds this
|
|
70
|
+
* from the injected concrete adapters; tests build it from the B0 Mocks + an
|
|
71
|
+
* in-memory persistence fake.
|
|
72
|
+
*/
|
|
73
|
+
export interface MediaProcessingDeps {
|
|
74
|
+
readonly storage: StoragePort;
|
|
75
|
+
readonly transcode: TranscodePort;
|
|
76
|
+
readonly transcribe: TranscribePort;
|
|
77
|
+
readonly moderation: MediaModerationProvider;
|
|
78
|
+
readonly persistence: MediaPersistencePort;
|
|
79
|
+
readonly config: MediaProcessingConfig;
|
|
80
|
+
/** The object-storage bucket handle moderation/transcription refs carry. */
|
|
81
|
+
readonly bucket: string;
|
|
82
|
+
/**
|
|
83
|
+
* Deterministic job-name factory for transcription/idempotency. Injected so
|
|
84
|
+
* the shell stays free of Date.now/Math.random in tests; production passes a
|
|
85
|
+
* uuid/time-based generator. `seed` is a stable per-call input (the cas key).
|
|
86
|
+
*/
|
|
87
|
+
readonly newJobName: (seed: string) => string;
|
|
88
|
+
readonly logger: Pick<Logger, "info" | "warn" | "error">;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The disposition of one SQS record after orchestration.
|
|
92
|
+
*
|
|
93
|
+
* - `ack` — remove from the queue (success, drop-non-pending, or poison routed
|
|
94
|
+
* to REVIEW). A poison ack carries `poison: true` for observability.
|
|
95
|
+
* - `fail` — leave on the queue for SQS to retry (transient/retryable fault).
|
|
96
|
+
* The handler maps this to a batchItemFailure.
|
|
97
|
+
*/
|
|
98
|
+
export type RecordOutcome = {
|
|
99
|
+
readonly disposition: "ack";
|
|
100
|
+
readonly reason: string;
|
|
101
|
+
readonly poison?: boolean;
|
|
102
|
+
} | {
|
|
103
|
+
readonly disposition: "fail";
|
|
104
|
+
readonly reason: string;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Parse a triggering key as a `pending/{tenantId}/{uploadId}` key, validating
|
|
108
|
+
* the FORM by round-tripping the parsed parts back through `pendingKey()`. A
|
|
109
|
+
* key only parses if rebuilding it from its parts yields the identical string —
|
|
110
|
+
* so a path-traversal payload, extra segments, or a malformed id can never pass
|
|
111
|
+
* (cas-keys.ts owns the anchored allowlists).
|
|
112
|
+
*
|
|
113
|
+
* @returns the {tenantId, uploadId} when the key is a canonical pending key,
|
|
114
|
+
* or null for ANY other key (which the caller ack-drops; we never
|
|
115
|
+
* write outputs under pending/, so a non-pending key is not our work).
|
|
116
|
+
*/
|
|
117
|
+
export declare function parsePendingKey(key: string): {
|
|
118
|
+
tenantId: string;
|
|
119
|
+
uploadId: string;
|
|
120
|
+
} | null;
|
|
121
|
+
/** Every object key referenced by one SQS record's S3 event notification. */
|
|
122
|
+
export declare function extractObjectKeys(recordBody: string): string[];
|
|
123
|
+
/**
|
|
124
|
+
* Orchestrate processing for ONE already-extracted object key.
|
|
125
|
+
*
|
|
126
|
+
* Steps (every uncertainty fails closed; nothing here can yield APPROVED):
|
|
127
|
+
* 1. Reject any key that is not a canonical `pending/{tenant}/{upload}` key —
|
|
128
|
+
* ack-drop it; outputs are NEVER written under pending/.
|
|
129
|
+
* 2. Load the MediaFile row by uploadId; re-derive tenant FROM THE ROW and
|
|
130
|
+
* assert pendingKey(rowTenant, uploadId) === the triggering key. Mismatch
|
|
131
|
+
* (or missing/uploadId-less row) is a hard reject → REVIEW + ack.
|
|
132
|
+
* 3. Probe duration; over-cap ⇒ poison ⇒ REVIEW + ack (no transcode).
|
|
133
|
+
* 4. Transcode-and-discard ⇒ cleaned bytes at the STAGING key (read back from
|
|
134
|
+
* the cleaned key). The cleaned bytes are NOT written to cas/ here — cas/ is
|
|
135
|
+
* the CDN-served prefix and must hold only APPROVED bytes (promotion happens
|
|
136
|
+
* in the completion worker).
|
|
137
|
+
* 5. Hash the cleaned bytes ⇒ realHash; PERSIST {contentHash: realHash,
|
|
138
|
+
* originalKey: casKey(tenant, realHash)} onto the row, replacing the
|
|
139
|
+
* upload-time uploadId placeholder so the completion worker can derive the
|
|
140
|
+
* promote target.
|
|
141
|
+
* 6. START moderation on the cleaned STAGING object (NOT the raw pending upload,
|
|
142
|
+
* NOT a cas/ key) — moderation must run on EXACTLY the bytes that will be
|
|
143
|
+
* served: provider.startVideoModeration ⇒ persist VISUAL job (+ threshold
|
|
144
|
+
* snapshot); transcribe.startTranscription ⇒ persist AUDIO job (+ snapshot).
|
|
145
|
+
* The worker only STARTS jobs + persists jobIds; it never fetches verdicts.
|
|
146
|
+
*/
|
|
147
|
+
export declare function processObjectKey(triggeringKey: string, deps: MediaProcessingDeps): Promise<RecordOutcome>;
|
|
148
|
+
/**
|
|
149
|
+
* Process one SQS record (which may carry several S3 object keys). The record
|
|
150
|
+
* fails (SQS retry) iff ANY of its keys produced a retryable fault; otherwise
|
|
151
|
+
* it is acked. Per-key poison is acked, never failed.
|
|
152
|
+
*/
|
|
153
|
+
export declare function processRecord(record: SQSRecord, deps: MediaProcessingDeps): Promise<RecordOutcome>;
|
|
154
|
+
/**
|
|
155
|
+
* Inject the concrete media-processing seams. The consuming app (Skybber) calls
|
|
156
|
+
* this once at Lambda cold start with its ffmpeg/MediaConvert TranscodePort, S3
|
|
157
|
+
* StoragePort, Transcribe TranscribePort, injected MediaModerationProvider, and
|
|
158
|
+
* a Prisma-backed MediaPersistencePort. Core ships NO concrete adapters.
|
|
159
|
+
*/
|
|
160
|
+
export declare function setMediaProcessingDeps(deps: MediaProcessingDeps): void;
|
|
161
|
+
/** Test helper: clear injected deps between cases. */
|
|
162
|
+
export declare function __resetMediaProcessingDeps(): void;
|
|
163
|
+
/**
|
|
164
|
+
* The SQS entry point. Preserves `reportBatchItemFailures` semantics: only the
|
|
165
|
+
* messageIds whose records produced a retryable fault are returned as batch
|
|
166
|
+
* item failures; everything else (success / drop / poison→REVIEW) is acked by
|
|
167
|
+
* omission.
|
|
168
|
+
*
|
|
169
|
+
* If no concrete deps were injected, the handler fails CLOSED: it throws, so the
|
|
170
|
+
* whole batch is retried rather than silently dropped. An un-wired worker must
|
|
171
|
+
* never ack-drop real uploads.
|
|
172
|
+
*/
|
|
2
173
|
export declare const handler: SQSHandler;
|
|
3
174
|
//# sourceMappingURL=media-processing-worker.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"media-processing-worker.d.ts","sourceRoot":"","sources":["../../src/lambda/media-processing-worker.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"media-processing-worker.d.ts","sourceRoot":"","sources":["../../src/lambda/media-processing-worker.ts"],"names":[],"mappings":"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"}
|