@de-otio/trellis 0.12.3 → 0.14.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 (40) hide show
  1. package/dist/env.d.ts +10 -0
  2. package/dist/env.d.ts.map +1 -1
  3. package/dist/env.js +3 -0
  4. package/dist/env.js.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/lambda/media-processing-worker.d.ts +11 -1
  10. package/dist/lambda/media-processing-worker.d.ts.map +1 -1
  11. package/dist/lambda/media-processing-worker.js +38 -11
  12. package/dist/lambda/media-processing-worker.js.map +1 -1
  13. package/dist/lib/media/cas-keys.d.ts +15 -0
  14. package/dist/lib/media/cas-keys.d.ts.map +1 -1
  15. package/dist/lib/media/cas-keys.js +27 -0
  16. package/dist/lib/media/cas-keys.js.map +1 -1
  17. package/dist/lib/media/media-ports.d.ts +14 -0
  18. package/dist/lib/media/media-ports.d.ts.map +1 -1
  19. package/dist/lib/media/media-ports.js +9 -0
  20. package/dist/lib/media/media-ports.js.map +1 -1
  21. package/dist/lib/media/media-upsert.d.ts +9 -0
  22. package/dist/lib/media/media-upsert.d.ts.map +1 -1
  23. package/dist/lib/media/media-upsert.js +5 -0
  24. package/dist/lib/media/media-upsert.js.map +1 -1
  25. package/dist/lib/media/moderation-status.d.ts +12 -0
  26. package/dist/lib/media/moderation-status.d.ts.map +1 -1
  27. package/dist/lib/media/moderation-status.js +15 -0
  28. package/dist/lib/media/moderation-status.js.map +1 -1
  29. package/dist/lib/media/request-moderation.d.ts +18 -0
  30. package/dist/lib/media/request-moderation.d.ts.map +1 -0
  31. package/dist/lib/media/request-moderation.js +45 -0
  32. package/dist/lib/media/request-moderation.js.map +1 -0
  33. package/dist/lib/media/route-upload.d.ts +4 -3
  34. package/dist/lib/media/route-upload.d.ts.map +1 -1
  35. package/dist/lib/media/route-upload.js.map +1 -1
  36. package/dist/lib/routes/media.d.ts.map +1 -1
  37. package/dist/lib/routes/media.js +134 -26
  38. package/dist/lib/routes/media.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/lambda/media-processing-worker.ts +48 -12
@@ -1 +1 @@
1
- {"version":3,"file":"moderation-status.d.ts","sourceRoot":"","sources":["../../../src/lib/media/moderation-status.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,MAAM,gBAAgB,GACxB,SAAS,GACT,UAAU,GACV,QAAQ,GACR,aAAa,GACb,UAAU,CAAC;AAEf;;;;;GAKG;AACH,MAAM,MAAM,kBAAkB,GAAG,UAAU,GAAG,QAAQ,GAAG,YAAY,CAAC;AAEtE;;;;;;;;GAQG;AACH,MAAM,MAAM,eAAe,GACvB;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,kBAAkB,CAAA;CAAE,GACpE;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,GAAG,QAAQ,CAAA;CAAE,GACjE;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9B;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,MAAM,EAAE,oBAAoB,CAAC;IACtC,QAAQ,CAAC,IAAI,EAAE,gBAAgB,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;CACjC,CAAC;AAEF,gDAAgD;AAChD,MAAM,MAAM,gBAAgB,GACxB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAA;CAAE,GACxD,iBAAiB,CAAC;AAwBtB;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,gBAAgB,EACzB,KAAK,EAAE,eAAe,GACrB,gBAAgB,CAoClB;AA4BD,6EAA6E;AAC7E,eAAO,MAAM,uBAAuB,EAAE,SAAS,gBAAgB,EAExC,CAAC;AAExB,iFAAiF;AACjF,eAAO,MAAM,wBAAwB,EAAE,SAAS,kBAAkB,EAIxD,CAAC"}
1
+ {"version":3,"file":"moderation-status.d.ts","sourceRoot":"","sources":["../../../src/lib/media/moderation-status.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,MAAM,gBAAgB,GACxB,SAAS,GACT,UAAU,GACV,QAAQ,GACR,aAAa,GACb,UAAU,CAAC;AAEf;;;;;GAKG;AACH,MAAM,MAAM,kBAAkB,GAAG,UAAU,GAAG,QAAQ,GAAG,YAAY,CAAC;AAEtE;;;;;;;;GAQG;AACH,MAAM,MAAM,eAAe,GACvB;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,kBAAkB,CAAA;CAAE,GACpE;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,GAAG,QAAQ,CAAA;CAAE,GACjE;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9B;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,MAAM,EAAE,oBAAoB,CAAC;IACtC,QAAQ,CAAC,IAAI,EAAE,gBAAgB,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;CACjC,CAAC;AAEF,gDAAgD;AAChD,MAAM,MAAM,gBAAgB,GACxB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAA;CAAE,GACxD,iBAAiB,CAAC;AAwBtB;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,gBAAgB,EACzB,KAAK,EAAE,eAAe,GACrB,gBAAgB,CAoClB;AASD;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,kBAAkB,GAC3B,gBAAgB,CAGlB;AAqBD,6EAA6E;AAC7E,eAAO,MAAM,uBAAuB,EAAE,SAAS,gBAAgB,EAExC,CAAC;AAExB,iFAAiF;AACjF,eAAO,MAAM,wBAAwB,EAAE,SAAS,kBAAkB,EAIxD,CAAC"}
@@ -93,6 +93,21 @@ export function nextStatus(current, event) {
93
93
  function illegal(from, event) {
94
94
  return { ok: false, reason: "illegal-transition", from, event };
95
95
  }
96
+ /**
97
+ * Map a classifier {@link ModerationDecision} to the {@link ModerationStatus} a
98
+ * freshly-uploaded (PENDING) object should land in — a thin shell over
99
+ * {@link nextStatus} for the synchronous image-upload path.
100
+ *
101
+ * Drives the transition `PENDING --decision <d>--> status` and returns the
102
+ * resulting status. The transition out of PENDING on a `decision` event is
103
+ * always legal, but should the machine ever report a not-ok transition we fail
104
+ * closed to `REVIEW` (never `APPROVED`): an unexpected refusal must degrade to
105
+ * human review, not to serving.
106
+ */
107
+ export function decisionToStatus(decision) {
108
+ const result = nextStatus("PENDING", { kind: "decision", decision });
109
+ return result.ok ? result.status : "REVIEW";
110
+ }
96
111
  /**
97
112
  * Compile-time exhaustiveness guard for {@link ModerationStatus}.
98
113
  *
@@ -1 +1 @@
1
- {"version":3,"file":"moderation-status.js","sourceRoot":"","sources":["../../../src/lib/media/moderation-status.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AA4DH;;;;;;;GAOG;AACH,SAAS,iBAAiB,CAAC,QAA4B;IACrD,QAAQ,QAAQ,EAAE,CAAC;QACjB,KAAK,UAAU;YACb,OAAO,UAAU,CAAC;QACpB,KAAK,QAAQ;YACX,OAAO,QAAQ,CAAC;QAClB,KAAK,YAAY;YACf,OAAO,aAAa,CAAC;QACvB;YACE,8DAA8D;YAC9D,OAAO,QAAQ,CAAC;IACpB,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,UAAU,CACxB,OAAyB,EACzB,KAAsB;IAEtB,wEAAwE;IACxE,gCAAgC;IAChC,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;QAC1B,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IAC1C,CAAC;IAED,QAAQ,OAAO,EAAE,CAAC;QAChB,KAAK,SAAS,CAAC,CAAC,CAAC;YACf,yDAAyD;YACzD,IAAI,KAAK,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;gBAC9B,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,iBAAiB,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAC;YACjE,CAAC;YACD,OAAO,OAAO,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACjC,CAAC;QAED,KAAK,QAAQ,CAAC;QACd,KAAK,aAAa,CAAC,CAAC,CAAC;YACnB,yCAAyC;YACzC,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;gBAC3B,OAAO;oBACL,EAAE,EAAE,IAAI;oBACR,MAAM,EAAE,KAAK,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,UAAU;iBAC7D,CAAC;YACJ,CAAC;YACD,OAAO,OAAO,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACjC,CAAC;QAED,KAAK,UAAU,CAAC;QAChB,KAAK,UAAU;YACb,sCAAsC;YACtC,OAAO,OAAO,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QAEjC;YACE,OAAO,OAAO,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IACnC,CAAC;AACH,CAAC;AAED,SAAS,OAAO,CACd,IAAsB,EACtB,KAAsB;IAEtB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,oBAAoB,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAClE,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,yBAAyB,GAAG;IAChC,OAAO,EAAE,IAAI;IACb,QAAQ,EAAE,IAAI;IACd,MAAM,EAAE,IAAI;IACZ,WAAW,EAAE,IAAI;IACjB,QAAQ,EAAE,IAAI;CACmC,CAAC;AAEpD,6EAA6E;AAC7E,MAAM,CAAC,MAAM,uBAAuB,GAAgC,MAAM,CAAC,IAAI,CAC7E,yBAAyB,CACJ,CAAC;AAExB,iFAAiF;AACjF,MAAM,CAAC,MAAM,wBAAwB,GAAkC;IACrE,UAAU;IACV,QAAQ;IACR,YAAY;CACJ,CAAC"}
1
+ {"version":3,"file":"moderation-status.js","sourceRoot":"","sources":["../../../src/lib/media/moderation-status.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AA4DH;;;;;;;GAOG;AACH,SAAS,iBAAiB,CAAC,QAA4B;IACrD,QAAQ,QAAQ,EAAE,CAAC;QACjB,KAAK,UAAU;YACb,OAAO,UAAU,CAAC;QACpB,KAAK,QAAQ;YACX,OAAO,QAAQ,CAAC;QAClB,KAAK,YAAY;YACf,OAAO,aAAa,CAAC;QACvB;YACE,8DAA8D;YAC9D,OAAO,QAAQ,CAAC;IACpB,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,UAAU,CACxB,OAAyB,EACzB,KAAsB;IAEtB,wEAAwE;IACxE,gCAAgC;IAChC,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;QAC1B,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IAC1C,CAAC;IAED,QAAQ,OAAO,EAAE,CAAC;QAChB,KAAK,SAAS,CAAC,CAAC,CAAC;YACf,yDAAyD;YACzD,IAAI,KAAK,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;gBAC9B,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,iBAAiB,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAC;YACjE,CAAC;YACD,OAAO,OAAO,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACjC,CAAC;QAED,KAAK,QAAQ,CAAC;QACd,KAAK,aAAa,CAAC,CAAC,CAAC;YACnB,yCAAyC;YACzC,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;gBAC3B,OAAO;oBACL,EAAE,EAAE,IAAI;oBACR,MAAM,EAAE,KAAK,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,UAAU;iBAC7D,CAAC;YACJ,CAAC;YACD,OAAO,OAAO,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACjC,CAAC;QAED,KAAK,UAAU,CAAC;QAChB,KAAK,UAAU;YACb,sCAAsC;YACtC,OAAO,OAAO,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QAEjC;YACE,OAAO,OAAO,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IACnC,CAAC;AACH,CAAC;AAED,SAAS,OAAO,CACd,IAAsB,EACtB,KAAsB;IAEtB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,oBAAoB,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAClE,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,gBAAgB,CAC9B,QAA4B;IAE5B,MAAM,MAAM,GAAG,UAAU,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,CAAC;IACrE,OAAO,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC;AAC9C,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,yBAAyB,GAAG;IAChC,OAAO,EAAE,IAAI;IACb,QAAQ,EAAE,IAAI;IACd,MAAM,EAAE,IAAI;IACZ,WAAW,EAAE,IAAI;IACjB,QAAQ,EAAE,IAAI;CACmC,CAAC;AAEpD,6EAA6E;AAC7E,MAAM,CAAC,MAAM,uBAAuB,GAAgC,MAAM,CAAC,IAAI,CAC7E,yBAAyB,CACJ,CAAC;AAExB,iFAAiF;AACjF,MAAM,CAAC,MAAM,wBAAwB,GAAkC;IACrE,UAAU;IACV,QAAQ;IACR,YAAY;CACJ,CAAC"}
@@ -0,0 +1,18 @@
1
+ import { type MediaModerationProvider } from "./moderation-provider.js";
2
+ /**
3
+ * Consuming app (Skybber) calls this at startup with its concrete moderation
4
+ * provider (e.g. an AWS Rekognition adapter). MUST run before the upload route
5
+ * serves. Re-exported from `@de-otio/trellis` (apps/api/src/index.ts).
6
+ */
7
+ export declare function setMediaModerationProvider(provider: MediaModerationProvider): void;
8
+ /**
9
+ * Returns the injected provider if one was registered, else a fail-closed
10
+ * {@link NullModerationProvider} (every verdict = `review`). The upload handler
11
+ * calls this on each sync-image request. Defaulting to Null — rather than
12
+ * throwing — means an un-wired deploy degrades to REVIEW (never serves, never
13
+ * 500), which is the safe behaviour for a moderation seam.
14
+ */
15
+ export declare function getMediaModerationProvider(): MediaModerationProvider;
16
+ /** Test-only: clear the injected provider so tests don't leak across cases. */
17
+ export declare function __resetMediaModerationProviderForTests(): void;
18
+ //# sourceMappingURL=request-moderation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request-moderation.d.ts","sourceRoot":"","sources":["../../../src/lib/media/request-moderation.ts"],"names":[],"mappings":"AAeA,OAAO,EACL,KAAK,uBAAuB,EAE7B,MAAM,0BAA0B,CAAC;AAWlC;;;;GAIG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,uBAAuB,GAChC,IAAI,CAEN;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,IAAI,uBAAuB,CAEpE;AAED,+EAA+E;AAC/E,wBAAgB,sCAAsC,IAAI,IAAI,CAE7D"}
@@ -0,0 +1,45 @@
1
+ // CONTRACT: stable — coordinate changes.
2
+ //
3
+ // Request-path provider seam for SYNCHRONOUS image moderation. Mirrors the
4
+ // realtime-transport seam (apps/api/src/lib/realtime/index.ts): a consuming app
5
+ // (Skybber) calls setMediaModerationProvider() ONCE at startup with its concrete
6
+ // cloud adapter, and the upload handler reads it via getMediaModerationProvider()
7
+ // on every sync-image request. Core imports no cloud SDK.
8
+ //
9
+ // FAIL-CLOSED DEFAULT: when no provider has been injected, getMediaModerationProvider()
10
+ // returns a NullModerationProvider (every verdict = "review") rather than throwing.
11
+ // An un-wired deploy must degrade to REVIEW (never serve, never 500) — a thrown
12
+ // error here would turn every image upload into a 500. The startup guard
13
+ // (assertModerationProviderAllowed) is the place that loudly refuses to run Null
14
+ // outside dev; this seam is the safe runtime fallback.
15
+ import { NullModerationProvider, } from "./moderation-provider.js";
16
+ // ---------------------------------------------------------------------------
17
+ // Provider-injection hook (mirrors setRealtimeProvider). A consuming app calls
18
+ // setMediaModerationProvider() at startup, BEFORE the upload route serves, with
19
+ // its concrete provider. getMediaModerationProvider() returns the injected
20
+ // provider if present, else a fail-closed Null default.
21
+ // ---------------------------------------------------------------------------
22
+ let injected;
23
+ /**
24
+ * Consuming app (Skybber) calls this at startup with its concrete moderation
25
+ * provider (e.g. an AWS Rekognition adapter). MUST run before the upload route
26
+ * serves. Re-exported from `@de-otio/trellis` (apps/api/src/index.ts).
27
+ */
28
+ export function setMediaModerationProvider(provider) {
29
+ injected = provider;
30
+ }
31
+ /**
32
+ * Returns the injected provider if one was registered, else a fail-closed
33
+ * {@link NullModerationProvider} (every verdict = `review`). The upload handler
34
+ * calls this on each sync-image request. Defaulting to Null — rather than
35
+ * throwing — means an un-wired deploy degrades to REVIEW (never serves, never
36
+ * 500), which is the safe behaviour for a moderation seam.
37
+ */
38
+ export function getMediaModerationProvider() {
39
+ return injected ?? new NullModerationProvider();
40
+ }
41
+ /** Test-only: clear the injected provider so tests don't leak across cases. */
42
+ export function __resetMediaModerationProviderForTests() {
43
+ injected = undefined;
44
+ }
45
+ //# sourceMappingURL=request-moderation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request-moderation.js","sourceRoot":"","sources":["../../../src/lib/media/request-moderation.ts"],"names":[],"mappings":"AAAA,yCAAyC;AACzC,EAAE;AACF,2EAA2E;AAC3E,gFAAgF;AAChF,iFAAiF;AACjF,kFAAkF;AAClF,0DAA0D;AAC1D,EAAE;AACF,wFAAwF;AACxF,oFAAoF;AACpF,gFAAgF;AAChF,yEAAyE;AACzE,iFAAiF;AACjF,uDAAuD;AAEvD,OAAO,EAEL,sBAAsB,GACvB,MAAM,0BAA0B,CAAC;AAElC,8EAA8E;AAC9E,+EAA+E;AAC/E,gFAAgF;AAChF,2EAA2E;AAC3E,wDAAwD;AACxD,8EAA8E;AAE9E,IAAI,QAA6C,CAAC;AAElD;;;;GAIG;AACH,MAAM,UAAU,0BAA0B,CACxC,QAAiC;IAEjC,QAAQ,GAAG,QAAQ,CAAC;AACtB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,0BAA0B;IACxC,OAAO,QAAQ,IAAI,IAAI,sBAAsB,EAAE,CAAC;AAClD,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,sCAAsC;IACpD,QAAQ,GAAG,SAAS,CAAC;AACvB,CAAC"}
@@ -24,9 +24,10 @@
24
24
  /**
25
25
  * The three ingest routes for an uploaded object.
26
26
  *
27
- * - `sync-image` — re-encode synchronously; the upload handler completes
28
- * the full pipeline inline and records the object as APPROVED
29
- * after the re-encode pass.
27
+ * - `sync-image` — re-encode synchronously, then determine
28
+ * APPROVED/REVIEW/QUARANTINED via the injected
29
+ * moderateImage provider; the handler stages the cleaned
30
+ * bytes and promotes them to cas/ only on APPROVED.
30
31
  * - `async-pending` — store as-is, record as PENDING, fan out to the P0b
31
32
  * async processing worker.
32
33
  * - `reject` — refuse the upload at the type-routing boundary (before
@@ -1 +1 @@
1
- {"version":3,"file":"route-upload.d.ts","sourceRoot":"","sources":["../../../src/lib/media/route-upload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,MAAM,WAAW,GACnB;IAAE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAA;CAAE,GAC/B;IAAE,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAA;CAAE,GAClC;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC;AAOhC;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,WAAW,CAyC5D"}
1
+ {"version":3,"file":"route-upload.d.ts","sourceRoot":"","sources":["../../../src/lib/media/route-upload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,WAAW,GACnB;IAAE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAA;CAAE,GAC/B;IAAE,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAA;CAAE,GAClC;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC;AAOhC;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,WAAW,CAyC5D"}
@@ -1 +1 @@
1
- {"version":3,"file":"route-upload.js","sourceRoot":"","sources":["../../../src/lib/media/route-upload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAkBH,gEAAgE;AAChE,MAAM,UAAU,GAAgB,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC;AACvD,MAAM,aAAa,GAAgB,EAAE,IAAI,EAAE,eAAe,EAAE,CAAC;AAC7D,MAAM,MAAM,GAAgB,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;AAE/C;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,WAAW,CAAC,WAAmB;IAC7C,wCAAwC;IACxC,IAAI,CAAC,WAAW,IAAI,OAAO,WAAW,KAAK,QAAQ,EAAE,CAAC;QACpD,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,2EAA2E;IAC3E,8CAA8C;IAC9C,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAE5D,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,8EAA8E;IAC9E,kEAAkE;IAClE,2EAA2E;IAC3E,oDAAoD;IACpD,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,YAAY,CAAC;QAClB,KAAK,WAAW,CAAC,CAAC,6CAA6C;QAC/D,KAAK,WAAW,CAAC;QACjB,KAAK,YAAY,CAAC;QAClB,KAAK,WAAW,EAAE,qDAAqD;YACrE,OAAO,UAAU,CAAC;IACtB,CAAC;IAED,8EAA8E;IAC9E,oEAAoE;IACpE,IAAI,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC3D,sEAAsE;QACtE,gCAAgC;QAChC,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;QAClD,IAAI,OAAO,EAAE,CAAC;YACZ,OAAO,aAAa,CAAC;QACvB,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,wEAAwE;IACxE,OAAO,MAAM,CAAC;AAChB,CAAC"}
1
+ {"version":3,"file":"route-upload.js","sourceRoot":"","sources":["../../../src/lib/media/route-upload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAmBH,gEAAgE;AAChE,MAAM,UAAU,GAAgB,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC;AACvD,MAAM,aAAa,GAAgB,EAAE,IAAI,EAAE,eAAe,EAAE,CAAC;AAC7D,MAAM,MAAM,GAAgB,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;AAE/C;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,WAAW,CAAC,WAAmB;IAC7C,wCAAwC;IACxC,IAAI,CAAC,WAAW,IAAI,OAAO,WAAW,KAAK,QAAQ,EAAE,CAAC;QACpD,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,2EAA2E;IAC3E,8CAA8C;IAC9C,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAE5D,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,8EAA8E;IAC9E,kEAAkE;IAClE,2EAA2E;IAC3E,oDAAoD;IACpD,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,YAAY,CAAC;QAClB,KAAK,WAAW,CAAC,CAAC,6CAA6C;QAC/D,KAAK,WAAW,CAAC;QACjB,KAAK,YAAY,CAAC;QAClB,KAAK,WAAW,EAAE,qDAAqD;YACrE,OAAO,UAAU,CAAC;IACtB,CAAC;IAED,8EAA8E;IAC9E,oEAAoE;IACpE,IAAI,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC3D,sEAAsE;QACtE,gCAAgC;QAChC,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;QAClD,IAAI,OAAO,EAAE,CAAC;YACZ,OAAO,aAAa,CAAC;QACvB,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,wEAAwE;IACxE,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../../src/lib/routes/media.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AA+BH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAoPxC;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,GAAG,EACR,OAAO,EAAE;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAC1B,OAAO,CAAC,QAAQ,CAAC,CA+HnB;AAoDD,eAAO,MAAM,WAAW,EAAE,KAAK,EA8xE9B,CAAC"}
1
+ {"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../../src/lib/routes/media.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAiCH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAoPxC;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,GAAG,EACR,OAAO,EAAE;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAC1B,OAAO,CAAC,QAAQ,CAAC,CA+HnB;AAoDD,eAAO,MAAM,WAAW,EAAE,KAAK,EAi5E9B,CAAC"}
@@ -9,9 +9,11 @@ import { CorsHandler } from "../cors-handler.js";
9
9
  import { sharedDatabaseConnectionManager } from "../database-connection-manager.js";
10
10
  import { QueryTimeoutPresets, withQueryTimeoutAndRetry, } from "../db-query-helper.js";
11
11
  import { getLogger } from "../logger.js";
12
- import { casKey, isCasKeyError, pendingKey, validateContentHash } from "../media/cas-keys.js";
12
+ import { casKey, isCasKeyError, pendingKey, processingKey, validateContentHash } from "../media/cas-keys.js";
13
13
  import { buildMediaUpsertArgs } from "../media/media-upsert.js";
14
14
  import { checkUploadQuota } from "../media/quota-check.js";
15
+ import { decisionToStatus } from "../media/moderation-status.js";
16
+ import { getMediaModerationProvider } from "../media/request-moderation.js";
15
17
  import { canonicalContentType, isServable, } from "../media/serve-gate.js";
16
18
  import { resolveMediaTenantId } from "../media/tenant-resolution.js";
17
19
  import { routeUpload } from "../media/route-upload.js";
@@ -903,19 +905,29 @@ export const mediaRoutes = [
903
905
  height: extracted?.exifData?.height || extracted?.videoMetadata?.height,
904
906
  duration: extracted?.videoMetadata?.duration,
905
907
  };
906
- // Use MediaUploadService for eventual consistency upload.
907
- // Pass the re-encoded buffer (uploadBuffer) so the content hash is of
908
- // the clean output bytes, not the raw upload. The service writes the
909
- // bytes to the canonical CAS key `cas/{tenantId}/{hash}`.
910
- const uploadService = new MediaUploadService(env);
911
- const result = await uploadService.uploadSingle(file, session.userId, tenantId, metadata, uploadBuffer);
912
- // T9: the DB originalKey stores the SAME canonical CAS key the bytes
913
- // were written to, so the serve path reads exactly what upload wrote.
914
- const uploadOriginalKey = casKey(tenantId, result.contentHash);
915
- if (isCasKeyError(uploadOriginalKey)) {
916
- logger.error("[Media Upload] Failed to build CAS key", {
908
+ // --- STAGE MODERATE PROMOTE-ON-APPROVE -----------------------------
909
+ // Synchronous image moderation. The cleaned (re-encoded) bytes are
910
+ // written to a STAGING key first, moderated, and only PROMOTED to the
911
+ // canonical `cas/` key when the verdict is APPROVED. `cas/` therefore
912
+ // only ever holds approved bytes; REVIEW/QUARANTINED bytes stay at
913
+ // staging out of the serve path (the gate is APPROVED-only). FAIL-CLOSED
914
+ // throughout any uncertainty resolves to REVIEW, never APPROVED.
915
+ // 1. Hash the CLEANED output bytes so the CAS key addresses clean bytes
916
+ // (the same scheme MediaUploadService used: SHA-256 hex of the buffer).
917
+ const hashBuffer = await crypto.subtle.digest("SHA-256", uploadBuffer);
918
+ const contentHash = Array.from(new Uint8Array(hashBuffer))
919
+ .map((b) => b.toString(16).padStart(2, "0"))
920
+ .join("");
921
+ // 2. Build the canonical cas/ key (final serve location) and the
922
+ // processing/ staging key (pre-promotion location).
923
+ const uploadOriginalKey = casKey(tenantId, contentHash);
924
+ const stagingKey = processingKey(tenantId, contentHash);
925
+ if (isCasKeyError(uploadOriginalKey) || isCasKeyError(stagingKey)) {
926
+ logger.error("[Media Upload] Failed to build CAS/staging key", {
917
927
  userId: session.userId,
918
- kind: uploadOriginalKey.kind,
928
+ kind: isCasKeyError(uploadOriginalKey)
929
+ ? uploadOriginalKey.kind
930
+ : stagingKey.kind,
919
931
  });
920
932
  const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({
921
933
  error: "Database error",
@@ -923,9 +935,95 @@ export const mediaRoutes = [
923
935
  }), { status: 500, headers: { "content-type": "application/json" } });
924
936
  return CorsHandler.addCorsHeaders(errorResponse, request, env);
925
937
  }
926
- // Create MediaFile DB record synchronously so post creation can
927
- // reference it immediately (reconciliation will enrich it later).
928
- // Dedup is within-tenant via @@unique([tenantId, contentHash]) (D18).
938
+ const mediaBucket = env.MEDIA_BUCKET_R2 || env.R2_BUCKET;
939
+ if (!mediaBucket) {
940
+ // No object store to stage into — fail closed (cannot moderate, must
941
+ // not serve). Mirrors the async-pending path's 503.
942
+ logger.error("[Media Upload] No media bucket configured for staging", {
943
+ userId: session.userId,
944
+ });
945
+ const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Upload unavailable" }), { status: 503, headers: { "content-type": "application/json" } });
946
+ return CorsHandler.addCorsHeaders(errorResponse, request, env);
947
+ }
948
+ // 3. Write the cleaned bytes to STAGING (NOT cas/). cas/ is written only
949
+ // on APPROVED, below.
950
+ try {
951
+ await mediaBucket.put(stagingKey, uploadBuffer, {
952
+ httpMetadata: { contentType: mimeType },
953
+ });
954
+ }
955
+ catch (stageError) {
956
+ logger.error("[Media Upload] Staging write failed", {
957
+ userId: session.userId,
958
+ error: stageError?.message,
959
+ });
960
+ const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Upload failed" }), { status: 500, headers: { "content-type": "application/json" } });
961
+ return CorsHandler.addCorsHeaders(errorResponse, request, env);
962
+ }
963
+ // 4. Moderate the STAGED object via the injected provider. FAIL-CLOSED:
964
+ // any throw/timeout is treated as `review` (→ REVIEW, never promoted).
965
+ // The provider owns all thresholds (threshold-secrecy); core passes no
966
+ // numbers.
967
+ // The bucket handle the moderation ref carries is the RESOLVED media
968
+ // bucket name from the env — identical to what MEDIA_BUCKET_R2 (the
969
+ // binding the staging write went to) wraps, including the
970
+ // `${stage}-${appName}-media` fallback. Reading it back from the env
971
+ // (never re-deriving the name or fallback here) guarantees the staging
972
+ // WRITE and the moderation READ point at the same bucket, so it is never
973
+ // empty. The injected provider uses {bucket, key} to locate the STAGED
974
+ // object.
975
+ const moderationBucketName = env.MEDIA_BUCKET_NAME;
976
+ let decision;
977
+ try {
978
+ const verdict = await getMediaModerationProvider().moderateImage({
979
+ bucket: moderationBucketName,
980
+ key: stagingKey,
981
+ });
982
+ decision = decisionToStatus(verdict.decision);
983
+ }
984
+ catch (moderationError) {
985
+ logger.warn("[Media Upload] Image moderation failed — failing closed to REVIEW", {
986
+ userId: session.userId,
987
+ error: moderationError?.message,
988
+ });
989
+ decision = "REVIEW";
990
+ }
991
+ // 5. PROMOTE on APPROVED: copy staging → cas/ (the cleaned bytes we
992
+ // already hold in memory), then best-effort delete the staging copy.
993
+ // Anything else (REVIEW/QUARANTINED/REJECTED) leaves the bytes at
994
+ // staging and NEVER writes cas/.
995
+ if (decision === "APPROVED") {
996
+ try {
997
+ await mediaBucket.put(uploadOriginalKey, uploadBuffer, {
998
+ httpMetadata: { contentType: mimeType },
999
+ });
1000
+ }
1001
+ catch (promoteError) {
1002
+ // Promotion failed — the bytes are still safely at staging and the
1003
+ // row has not been written. Fail the upload so the client retries
1004
+ // rather than recording an APPROVED row with no servable cas/ object.
1005
+ logger.error("[Media Upload] CAS promotion failed", {
1006
+ userId: session.userId,
1007
+ error: promoteError?.message,
1008
+ });
1009
+ const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Upload failed" }), { status: 500, headers: { "content-type": "application/json" } });
1010
+ return CorsHandler.addCorsHeaders(errorResponse, request, env);
1011
+ }
1012
+ // Best-effort staging cleanup — cas/ is what serves, so a leftover
1013
+ // staging object is harmless (lifecycle-expired) and never fatal.
1014
+ try {
1015
+ await mediaBucket.delete(stagingKey);
1016
+ }
1017
+ catch (deleteError) {
1018
+ logger.warn("[Media Upload] Staging delete tolerated", {
1019
+ userId: session.userId,
1020
+ error: deleteError?.message,
1021
+ });
1022
+ }
1023
+ }
1024
+ // 6. Create the MediaFile DB record synchronously so post creation can
1025
+ // reference it immediately. Dedup is within-tenant via
1026
+ // @@unique([tenantId, contentHash]) (D18).
929
1027
  try {
930
1028
  await withQueryTimeoutAndRetry(sharedDatabaseConnectionManager, uploadRegion, env, async (db) => {
931
1029
  // T9: a within-tenant dedup hit (identical bytes re-uploaded)
@@ -933,10 +1031,10 @@ export const mediaRoutes = [
933
1031
  // buildMediaUpsertArgs guarantees the `update` payload touches
934
1032
  // neither uploadedBy nor moderationStatus — subsequent uploaders
935
1033
  // get a reference (via the post→media relation), not a mutation
936
- // of the shared row.
1034
+ // of the shared row. The verdict applies to the `create` only.
937
1035
  return await db.mediaFile.upsert(buildMediaUpsertArgs({
938
1036
  tenantId,
939
- contentHash: result.contentHash,
1037
+ contentHash,
940
1038
  mimeType,
941
1039
  size: file.size,
942
1040
  originalKey: uploadOriginalKey,
@@ -944,6 +1042,7 @@ export const mediaRoutes = [
944
1042
  width: metadata?.width,
945
1043
  height: metadata?.height,
946
1044
  duration: metadata?.duration,
1045
+ moderationStatus: decision,
947
1046
  }));
948
1047
  }, {
949
1048
  ...QueryTimeoutPresets.USER_FACING,
@@ -958,7 +1057,7 @@ export const mediaRoutes = [
958
1057
  // DB record is required for post creation to validate media ownership.
959
1058
  // If this fails, the upload must fail so the frontend can retry.
960
1059
  logger.error("[Media Upload] Synchronous DB record creation failed", {
961
- contentHash: result.contentHash,
1060
+ contentHash,
962
1061
  error: dbError.message,
963
1062
  });
964
1063
  const dbErrorResponse = securityHeaders.createSecureResponse(JSON.stringify({
@@ -971,9 +1070,10 @@ export const mediaRoutes = [
971
1070
  return CorsHandler.addCorsHeaders(dbErrorResponse, request, env);
972
1071
  }
973
1072
  logger.debug("[Media Upload] DB record created successfully", {
974
- contentHash: result.contentHash,
1073
+ contentHash,
975
1074
  uploadedBy: session.userId,
976
1075
  originalKey: uploadOriginalKey,
1076
+ moderationStatus: decision,
977
1077
  uploadRegion,
978
1078
  });
979
1079
  logger.info("Media upload successful", {
@@ -981,14 +1081,22 @@ export const mediaRoutes = [
981
1081
  fileName: file.name,
982
1082
  fileSize: file.size,
983
1083
  mimeType,
984
- contentHash: result.contentHash,
985
- status: result.status,
1084
+ contentHash,
1085
+ moderationStatus: decision,
986
1086
  });
1087
+ // Reconstruct the client serve URL (same scheme MediaUploadService
1088
+ // used). This is the public API URL the client GETs by hash — NOT a
1089
+ // storage key (the serve gate resolves the storage key from the DB row,
1090
+ // never by interpolating the hash; see serve-maze-removed.test.ts).
1091
+ const apiDomain = env.ENVIRONMENT === "prod"
1092
+ ? "https://api.example.com"
1093
+ : "https://api.rkm1.de";
1094
+ const serveUrl = `${apiDomain}/api/media/${encodeURIComponent(contentHash)}`;
987
1095
  const response = securityHeaders.createSecureResponse(JSON.stringify({
988
- url: result.url,
989
- mediaKey: result.contentHash,
990
- contentHash: result.contentHash,
991
- status: result.status,
1096
+ url: serveUrl,
1097
+ mediaKey: contentHash,
1098
+ contentHash,
1099
+ status: decision === "APPROVED" ? "approved" : "pending",
992
1100
  }), { status: 200, headers: { "content-type": "application/json" } });
993
1101
  return CorsHandler.addCorsHeaders(response, request, env);
994
1102
  }