@roottale/cms-media 0.71.0 → 0.72.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/README.md CHANGED
@@ -74,3 +74,21 @@ import { CfImage } from "@roottale/cms-media/react";
74
74
  - `r2KeyForTenant`, `normalizeFilename`, R2/S3 클라이언트 인터페이스 — 업로드(R2 원본 → CF Images upload-via-URL) 구현용.
75
75
 
76
76
  변형명/픽셀: thumbnail 96 · sm 320 · md 768 · lg 1600 (ADR-0034 §7.1.1).
77
+
78
+ ## 원본 보존과 백업 관측
79
+
80
+ `createMediaArchiveClient`의 v1 `preserve`·`findSource`·`verify`·`tombstone`은 실제 원격
81
+ 암호문 readback을 확인하는 업로드·삭제 경계다. 기존 `ArchiveReceipt` version 1은 유지한다.
82
+
83
+ 선택 메서드 `evidence`와 `reconcileSource`는 운영 백업의 v2 관측 전용이다.
84
+ `evidence`는 최대 10,000개 source의 캐시 증거를 배치 조회하고, `reconcileSource`는
85
+ 별도 scrub에서 source/receipt와 불변 객체 identity를 확인한다. `ArchiveIdentityProof`의
86
+ `byteVerifiedAt`과 `identityCheckedAt`은 서로 다른 검사 시각이다. 누락·만료 관측은
87
+ 미검증으로 취급하며 이 증거를 미디어 참조 확정이나 삭제 승인에 사용하지 않는다.
88
+ 선택 필드 `lastAttemptAt`은 gateway가 source 재대조 시도를 끝낸 시각을 canonical ISO
89
+ 문자열로 전달하는 스케줄링 힌트다. 성공·누락·만료 응답 모두에 사용할 수 있으며
90
+ 검증 성공이나 proof의 최신성을 뜻하지 않는다. 이 필드가 없는 기존 v2 응답도 허용한다.
91
+ `reconcileSource`는 최대 3회 읽기 시도를 재시도 대기까지 포함한 총 60초 안에서 수행한다.
92
+ 각 시도는 남은 시간만 사용하며, 최초 60초 제한은 gateway의 55초 작업 제한보다 길다.
93
+ 이 제한은 v1 조회·보존 요청의 기존 시간 제한과 재시도 계약을 바꾸지 않는다.
94
+ 운영 주기·배포 순서는 [백업 실행기 문서](../../tooling/backup/README.md)를 따른다.
package/dist/index.d.ts CHANGED
@@ -216,13 +216,7 @@ declare class S3R2Client implements R2Client {
216
216
  getPublicUrl(key: string): string;
217
217
  }
218
218
 
219
- /** Online archive stores only GPG ciphertext; applications never receive backup credentials. */
220
- interface MediaArchiveConfig {
221
- baseUrl: string;
222
- token: string;
223
- fetchImpl?: typeof fetch;
224
- sleep?: (ms: number) => Promise<void>;
225
- }
219
+ /** v1 preservation receipt: produced only after actual ciphertext readback. */
226
220
  interface ArchiveReceipt {
227
221
  version: 1;
228
222
  tenantId: string;
@@ -234,6 +228,47 @@ interface ArchiveReceipt {
234
228
  metadataCipherSha256: string;
235
229
  verifiedAt: string;
236
230
  }
231
+
232
+ /** Cached proof is a backup observation, never permission to create/delete media. */
233
+ declare const ARCHIVE_IDENTITY_MAX_AGE_MS: number;
234
+ declare const ARCHIVE_BYTES_MAX_AGE_MS: number;
235
+ declare const ARCHIVE_FULL_SCRUB_INTERVAL_MS: number;
236
+ declare const ARCHIVE_EVIDENCE_BATCH_LIMIT = 10000;
237
+ interface ArchiveObjectIdentity {
238
+ etag: string;
239
+ bytes: number;
240
+ }
241
+ interface ArchiveSourceLocator {
242
+ tenantId: string;
243
+ sourceBucket: string;
244
+ sourceKey: string;
245
+ }
246
+ interface ArchiveIdentityProof {
247
+ method: "receipt_identity";
248
+ byteVerifiedAt: string;
249
+ identityCheckedAt: string;
250
+ ciphertext: ArchiveObjectIdentity;
251
+ metadata: ArchiveObjectIdentity;
252
+ }
253
+ interface ArchiveSourceEvidence {
254
+ tenantId: string;
255
+ sourceDigest: string;
256
+ reason: "verified" | "evidence_missing" | "evidence_stale";
257
+ receipt: ArchiveReceipt | null;
258
+ proof: ArchiveIdentityProof | null;
259
+ /** Last completed source reconciliation attempt; scheduling only, never verification proof. */
260
+ lastAttemptAt?: string;
261
+ }
262
+ declare function archiveProofFresh(proof: ArchiveIdentityProof, now: number): boolean;
263
+ declare function parseArchiveIdentityProof(value: unknown): ArchiveIdentityProof;
264
+
265
+ /** Online archive stores only GPG ciphertext; applications never receive backup credentials. */
266
+ interface MediaArchiveConfig {
267
+ baseUrl: string;
268
+ token: string;
269
+ fetchImpl?: typeof fetch;
270
+ sleep?: (ms: number) => Promise<void>;
271
+ }
237
272
  interface PreserveMediaInput {
238
273
  tenantId: string;
239
274
  sourceKey: string;
@@ -259,6 +294,10 @@ interface MediaArchiveClient {
259
294
  sourceKey: string;
260
295
  }): Promise<ArchiveReceipt | null>;
261
296
  tombstone(input: MediaTombstoneInput): Promise<void>;
297
+ /** Read-only cached observation. Must never authorize an upload or deletion. */
298
+ evidence?(sources: ArchiveSourceLocator[]): Promise<ArchiveSourceEvidence[]>;
299
+ /** Separate scrub workload: verifies source/receipt identity and periodically bytes. */
300
+ reconcileSource?(source: ArchiveSourceLocator): Promise<ArchiveSourceEvidence>;
262
301
  }
263
302
  declare const ARCHIVE_FAILURE_REASONS: readonly ["missing_receipt", "checksum_mismatch", "invalid_receipt", "auth", "throttled", "timeout", "transport", "busy", "unavailable"];
264
303
  type ArchiveFailureReason = typeof ARCHIVE_FAILURE_REASONS[number];
@@ -321,4 +360,4 @@ declare function preserveStoredUpload(input: {
321
360
  receipt: ArchiveReceipt;
322
361
  }>;
323
362
 
324
- export { ARCHIVE_FAILURE_REASONS, type ArchiveFailureReason, type ArchiveReceipt, type CfImagesClient, type CfImagesConfig, type CfImagesCredentials, type FinalizePreservedUploadInput, type ImageVariant, type ImageVariantsResult, type MediaArchiveClient, type MediaArchiveConfig, MediaPreservationError, type MediaTombstoneInput, type NormalizedFilename, type PreservationStorage, type PreserveMediaInput, type R2Client, type R2Object, type R2PutOptions, type S3ClientConfig, type S3CommandFactories, S3R2Client, TenantR2, VARIANT_NAMES, VARIANT_SIZES, type VariantSizes, allVariantUrls, archiveOwnerPrefix, assertTenantObjectKey, buildImageVariants, cfImageSrcSet, cfImageVariant, createCfImagesClient, createMediaArchiveClient, finalizePreservedUpload, isCfImageUrl, mediaSha256, mediaUploadId, normalizeFilename, preserveStoredObject, preserveStoredUpload, r2KeyForTenant, seoFilenameFromFilename, suggestAltFromFilename, variantUrl };
363
+ export { ARCHIVE_BYTES_MAX_AGE_MS, ARCHIVE_EVIDENCE_BATCH_LIMIT, ARCHIVE_FAILURE_REASONS, ARCHIVE_FULL_SCRUB_INTERVAL_MS, ARCHIVE_IDENTITY_MAX_AGE_MS, type ArchiveFailureReason, type ArchiveIdentityProof, type ArchiveObjectIdentity, type ArchiveReceipt, type ArchiveSourceEvidence, type ArchiveSourceLocator, type CfImagesClient, type CfImagesConfig, type CfImagesCredentials, type FinalizePreservedUploadInput, type ImageVariant, type ImageVariantsResult, type MediaArchiveClient, type MediaArchiveConfig, MediaPreservationError, type MediaTombstoneInput, type NormalizedFilename, type PreservationStorage, type PreserveMediaInput, type R2Client, type R2Object, type R2PutOptions, type S3ClientConfig, type S3CommandFactories, S3R2Client, TenantR2, VARIANT_NAMES, VARIANT_SIZES, type VariantSizes, allVariantUrls, archiveOwnerPrefix, archiveProofFresh, assertTenantObjectKey, buildImageVariants, cfImageSrcSet, cfImageVariant, createCfImagesClient, createMediaArchiveClient, finalizePreservedUpload, isCfImageUrl, mediaSha256, mediaUploadId, normalizeFilename, parseArchiveIdentityProof, preserveStoredObject, preserveStoredUpload, r2KeyForTenant, seoFilenameFromFilename, suggestAltFromFilename, variantUrl };
package/dist/index.js CHANGED
@@ -254,6 +254,39 @@ var S3R2Client = class {
254
254
  }
255
255
  };
256
256
 
257
+ // src/archive-evidence.ts
258
+ var ARCHIVE_IDENTITY_MAX_AGE_MS = 45 * 6e4;
259
+ var ARCHIVE_BYTES_MAX_AGE_MS = 26 * 60 * 6e4;
260
+ var ARCHIVE_FULL_SCRUB_INTERVAL_MS = 24 * 60 * 6e4;
261
+ var ARCHIVE_EVIDENCE_BATCH_LIMIT = 1e4;
262
+ function archiveProofFresh(proof, now) {
263
+ const bytesAge = now - Date.parse(proof.byteVerifiedAt);
264
+ const identityAge = now - Date.parse(proof.identityCheckedAt);
265
+ return Number.isFinite(bytesAge) && bytesAge >= 0 && bytesAge <= ARCHIVE_BYTES_MAX_AGE_MS && Number.isFinite(identityAge) && identityAge >= 0 && identityAge <= ARCHIVE_IDENTITY_MAX_AGE_MS && Date.parse(proof.identityCheckedAt) >= Date.parse(proof.byteVerifiedAt);
266
+ }
267
+ function parseArchiveIdentityProof(value) {
268
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid archive proof");
269
+ const proof = value;
270
+ function time(value2) {
271
+ if (typeof value2 !== "string" || !Number.isFinite(Date.parse(value2)) || new Date(value2).toISOString() !== value2) throw new Error("Invalid archive proof date");
272
+ return value2;
273
+ }
274
+ function identity(value2) {
275
+ if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) throw new Error("Invalid archive identity");
276
+ const object = value2;
277
+ if (typeof object.etag !== "string" || !object.etag || object.etag.length > 256 || /[\x00-\x20\x7f]/.test(object.etag) || typeof object.bytes !== "number" || !Number.isSafeInteger(object.bytes) || object.bytes <= 0) throw new Error("Invalid archive identity");
278
+ return { etag: object.etag, bytes: object.bytes };
279
+ }
280
+ if (proof.method !== "receipt_identity") throw new Error("Invalid archive proof method");
281
+ return {
282
+ method: proof.method,
283
+ byteVerifiedAt: time(proof.byteVerifiedAt),
284
+ identityCheckedAt: time(proof.identityCheckedAt),
285
+ ciphertext: identity(proof.ciphertext),
286
+ metadata: identity(proof.metadata)
287
+ };
288
+ }
289
+
257
290
  // src/archive.ts
258
291
  var ARCHIVE_FAILURE_REASONS = ["missing_receipt", "checksum_mismatch", "invalid_receipt", "auth", "throttled", "timeout", "transport", "busy", "unavailable"];
259
292
  var MediaPreservationError = class extends Error {
@@ -313,9 +346,11 @@ function createMediaArchiveClient(config) {
313
346
  }
314
347
  const fetchImpl = config.fetchImpl ?? fetch;
315
348
  const sleep = config.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
316
- async function request(path, init, allowMissing = false) {
349
+ async function request(path, init, allowMissing = false, readOnly = false, timeoutMs = init.method === "GET" || readOnly ? 1e4 : 6e4, totalBudgetMs) {
317
350
  const started = Date.now();
318
351
  for (let attempt = 1; ; attempt++) {
352
+ const remaining = totalBudgetMs === void 0 ? timeoutMs : totalBudgetMs - (Date.now() - started);
353
+ if (remaining <= 0) throw new MediaPreservationError(void 0, "timeout");
319
354
  let retryAfterMs = 0;
320
355
  try {
321
356
  const headers = new Headers(init.headers);
@@ -325,7 +360,7 @@ function createMediaArchiveClient(config) {
325
360
  ...init,
326
361
  headers,
327
362
  redirect: "manual",
328
- signal: AbortSignal.timeout(init.method === "GET" ? 1e4 : 6e4)
363
+ signal: AbortSignal.timeout(Math.min(timeoutMs, remaining))
329
364
  });
330
365
  if (response.status === 404 && allowMissing) {
331
366
  await response.body?.cancel();
@@ -346,12 +381,74 @@ function createMediaArchiveClient(config) {
346
381
  error instanceof Error && ["TimeoutError", "AbortError"].includes(error.name) ? "timeout" : error instanceof SyntaxError ? "invalid_receipt" : "transport"
347
382
  );
348
383
  const delay = Math.max(retryAfterMs, 250 * 2 ** (attempt - 1) + Math.random() * 250);
349
- if (init.method !== "GET" || attempt >= 3 || !["transport", "timeout", "throttled", "busy", "unavailable"].includes(safe.reason) || Date.now() - started + delay + 1e4 > 35e3) throw safe;
384
+ const budgetExceeded = totalBudgetMs === void 0 ? Date.now() - started + delay + timeoutMs > 3 * timeoutMs + 5e3 : Date.now() - started + delay >= totalBudgetMs;
385
+ if (!readOnly && init.method !== "GET" || attempt >= 3 || !["transport", "timeout", "throttled", "busy", "unavailable"].includes(safe.reason) || budgetExceeded) throw safe;
350
386
  await sleep(delay);
351
387
  }
352
388
  }
353
389
  }
390
+ function validateSource(input) {
391
+ assertTenantObjectKey(input.tenantId, input.sourceKey);
392
+ if (!/^[a-z0-9][a-z0-9.-]{1,62}$/.test(input.sourceBucket)) throw new MediaPreservationError();
393
+ }
394
+ async function sourceDigest(input) {
395
+ validateSource(input);
396
+ return mediaSha256(new TextEncoder().encode(`${input.sourceBucket}
397
+ ${input.sourceKey}`).buffer);
398
+ }
399
+ async function evidenceItem(value, input) {
400
+ try {
401
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
402
+ const item = value;
403
+ const digest = await sourceDigest(input);
404
+ if (item.tenantId !== input.tenantId || item.sourceDigest !== digest) throw new Error();
405
+ let lastAttemptAt;
406
+ if (item.lastAttemptAt !== void 0) {
407
+ if (typeof item.lastAttemptAt !== "string" || !Number.isFinite(Date.parse(item.lastAttemptAt)) || new Date(item.lastAttemptAt).toISOString() !== item.lastAttemptAt) throw new Error();
408
+ lastAttemptAt = item.lastAttemptAt;
409
+ }
410
+ const attemptHint = lastAttemptAt === void 0 ? {} : { lastAttemptAt };
411
+ if (item.reason === "evidence_missing" || item.reason === "evidence_stale") {
412
+ if (item.receipt !== null || item.proof !== null) throw new Error();
413
+ return { tenantId: input.tenantId, sourceDigest: digest, reason: item.reason, receipt: null, proof: null, ...attemptHint };
414
+ }
415
+ if (item.reason !== "verified" || !item.receipt || typeof item.receipt !== "object") throw new Error();
416
+ const raw = item.receipt;
417
+ if (typeof raw.sha256 !== "string" || typeof raw.sizeBytes !== "number") throw new Error();
418
+ return {
419
+ tenantId: input.tenantId,
420
+ sourceDigest: digest,
421
+ reason: "verified",
422
+ receipt: readReceipt(raw, input.tenantId, raw.sha256, raw.sizeBytes),
423
+ proof: parseArchiveIdentityProof(item.proof),
424
+ ...attemptHint
425
+ };
426
+ } catch {
427
+ throw new MediaPreservationError();
428
+ }
429
+ }
354
430
  return {
431
+ async evidence(sources) {
432
+ if (sources.length > ARCHIVE_EVIDENCE_BATCH_LIMIT) throw new MediaPreservationError();
433
+ for (const source of sources) validateSource(source);
434
+ const value = await request("/v2/source-evidence", {
435
+ method: "POST",
436
+ headers: { "content-type": "application/json" },
437
+ body: JSON.stringify({ version: 2, sources })
438
+ }, false, true);
439
+ if (!value || typeof value !== "object" || !("version" in value) || value.version !== 2 || !("items" in value) || !Array.isArray(value.items) || value.items.length !== sources.length) throw new MediaPreservationError();
440
+ const parsed = [];
441
+ for (let start = 0; start < sources.length; start += 64) {
442
+ parsed.push(...await Promise.all(value.items.slice(start, start + 64).map((item, offset) => evidenceItem(item, sources[start + offset]))));
443
+ }
444
+ return parsed;
445
+ },
446
+ async reconcileSource(source) {
447
+ const digest = await sourceDigest(source);
448
+ const value = await request(`/v2/sources/${source.tenantId}/${digest}`, { method: "GET" }, true, false, 6e4, 6e4);
449
+ if (value === null) throw new MediaPreservationError(void 0, "missing_receipt");
450
+ return evidenceItem(value, source);
451
+ },
355
452
  async preserve(input) {
356
453
  assertTenantObjectKey(input.tenantId, input.sourceKey);
357
454
  const sha256 = await mediaSha256(input.bytes);
@@ -467,7 +564,11 @@ async function preserveStoredUpload(input) {
467
564
  return { key, receipt };
468
565
  }
469
566
  export {
567
+ ARCHIVE_BYTES_MAX_AGE_MS,
568
+ ARCHIVE_EVIDENCE_BATCH_LIMIT,
470
569
  ARCHIVE_FAILURE_REASONS,
570
+ ARCHIVE_FULL_SCRUB_INTERVAL_MS,
571
+ ARCHIVE_IDENTITY_MAX_AGE_MS,
471
572
  MediaPreservationError,
472
573
  S3R2Client,
473
574
  TenantR2,
@@ -475,6 +576,7 @@ export {
475
576
  VARIANT_SIZES,
476
577
  allVariantUrls,
477
578
  archiveOwnerPrefix,
579
+ archiveProofFresh,
478
580
  assertTenantObjectKey,
479
581
  buildImageVariants,
480
582
  cfImageSrcSet,
@@ -486,6 +588,7 @@ export {
486
588
  mediaSha256,
487
589
  mediaUploadId,
488
590
  normalizeFilename,
591
+ parseArchiveIdentityProof,
489
592
  preserveStoredObject,
490
593
  preserveStoredUpload,
491
594
  r2KeyForTenant,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/variants.ts","../src/cf-images.ts","../src/normalize-filename.ts","../src/r2-client.ts","../src/s3-client.ts","../src/archive.ts","../src/preserve-object.ts"],"sourcesContent":["// ADR-0034 §7.1.1 test #4 — CF Images variant 4종 URL 생성기.\n//\n// Phase 1 = thumbnail/sm/md/lg. CF Images delivery URL pattern:\n// https://imagedelivery.net/<account-hash>/<image-id>/<variant>\n\nexport type ImageVariant = \"thumbnail\" | \"sm\" | \"md\" | \"lg\";\n\nexport const VARIANT_NAMES: readonly ImageVariant[] = [\n \"thumbnail\",\n \"sm\",\n \"md\",\n \"lg\",\n] as const;\n\nexport interface VariantSizes {\n thumbnail: { width: 96; height: 96 };\n sm: { width: 320; height: 320 };\n md: { width: 768; height: 768 };\n lg: { width: 1600; height: 1600 };\n}\n\nexport const VARIANT_SIZES: VariantSizes = {\n thumbnail: { width: 96, height: 96 },\n sm: { width: 320, height: 320 },\n md: { width: 768, height: 768 },\n lg: { width: 1600, height: 1600 },\n};\n\nexport interface CfImagesConfig {\n accountHash: string;\n}\n\n/** CF Images delivery URL 생성. */\nexport function variantUrl(\n config: CfImagesConfig,\n imageId: string,\n variant: ImageVariant,\n): string {\n return `https://imagedelivery.net/${config.accountHash}/${imageId}/${variant}`;\n}\n\n/** 모든 variant 의 URL map. */\nexport function allVariantUrls(\n config: CfImagesConfig,\n imageId: string,\n): Record<ImageVariant, string> {\n return Object.fromEntries(\n VARIANT_NAMES.map((v) => [v, variantUrl(config, imageId, v)]),\n ) as Record<ImageVariant, string>;\n}\n\n// ── delivery URL 의 variant 세그먼트 교체 헬퍼 (account-hash/image-id 를 몰라도\n// 이미 받은 full delivery URL 만으로 variant 를 바꾼다. CfImage 렌더용.) ──\n\nconst CF_HOST = \"imagedelivery.net\";\nconst KNOWN_VARIANT_SUFFIX =\n /\\/(public|lg|md|sm|thumbnail|jhwebp|jhfull|jhthumb|jhmobile)$/;\n\n/** imagedelivery delivery URL 여부. */\nexport function isCfImageUrl(url: string): boolean {\n return typeof url === \"string\" && url.includes(CF_HOST);\n}\n\n/**\n * delivery URL 의 끝 variant 세그먼트를 교체. 세그먼트가 없으면 덧붙인다.\n * CF URL 이 아니면 원본 그대로 반환(외부/로컬 URL 안전 통과).\n */\nexport function cfImageVariant(\n url: string,\n variant: ImageVariant | \"public\",\n): string {\n if (!isCfImageUrl(url)) return url;\n return KNOWN_VARIANT_SUFFIX.test(url)\n ? url.replace(KNOWN_VARIANT_SUFFIX, `/${variant}`)\n : `${url}/${variant}`;\n}\n\n/** sm/md/lg srcset 문자열(CF URL 일 때만, 아니면 undefined). 폭은 VARIANT_SIZES 기준. */\nexport function cfImageSrcSet(\n url: string,\n variants: readonly ImageVariant[] = [\"sm\", \"md\", \"lg\"],\n): string | undefined {\n if (!isCfImageUrl(url)) return undefined;\n return variants\n .map((v) => `${cfImageVariant(url, v)} ${VARIANT_SIZES[v].width}w`)\n .join(\", \");\n}\n","// CF Images 업로드/삭제 seam (ADR-0034 §7.1.1 test #4) — variants.ts 가 delivery\n// URL 을 *조립*만 하던 것을, 실제 CF Images 업로드(→ image id)로 뒷받침한다.\n//\n// **이 파일이 단일 seam이다.** 예전엔 이 구현이 tenant-admin 안에만 있어서, 같은 일을\n// 하는 REST 업로드 경로(api-core `/v1/cms/media/uploads/complete`)가 CF Images 단계를\n// 통째로 빠뜨렸다. 그 결과 REST 로 올린 미디어는 `variants_json` 이 `{}` 로 남고,\n// 공개 posts API 는 featured 이미지를 variants_json 에서만 해석하므로\n// `featured_image_url` 이 영원히 null 이었다 (2026-07-26 발견 · 업로드·글 생성이\n// 모두 201 로 성공해 화면에서만 이미지가 빠지는 조용한 실패였다).\n//\n// 새 소비자는 반드시 이 팩토리를 쓸 것. 자격증명은 주입받는다 — Workers(env 바인딩)와\n// Next.js(process.env) 양쪽에서 같은 코드가 돌아야 하므로 여기서 env 를 읽지 않는다.\n\nimport { allVariantUrls, type CfImagesConfig, type ImageVariant } from \"./variants\";\n\n/** CF 가 sourceUrl 을 서버측에서 fetch → 보통 빠르나 큰 이미지 대비 넉넉히. */\nconst UPLOAD_TIMEOUT_MS = 15_000;\nconst DELETE_TIMEOUT_MS = 10_000;\n\nexport interface CfImagesCredentials {\n /** Cloudflare account id. */\n accountId: string;\n /** Images 쓰기 권한 토큰. */\n apiToken: string;\n /** imagedelivery.net account hash. */\n accountHash: string;\n /** 테스트/워커 주입용. 미지정 시 전역 fetch. */\n fetchImpl?: typeof fetch;\n}\n\nexport interface CfImagesClient {\n /**\n * R2 public URL 의 이미지를 CF Images 로 \"upload via URL\" 한다.\n * @returns CF Images image id. 실패 시 throw (호출부가 명확히 인지).\n */\n uploadFromUrl(sourceUrl: string): Promise<string>;\n /** image id → 4 variant(thumbnail/sm/md/lg) delivery URL map. */\n variantUrls(imageId: string): Record<ImageVariant, string>;\n /** CF Images 이미지 삭제 (orphan 방지). 실패 시 throw — 호출부가 best-effort 처리. */\n deleteImage(imageId: string): Promise<void>;\n}\n\n/**\n * CF Images 클라이언트. 자격증명 3종을 모두 받아야 만들 수 있다 —\n * 미설정 판단(graceful skip)은 호출부 책임이다.\n */\nexport function createCfImagesClient(\n credentials: CfImagesCredentials,\n): CfImagesClient {\n const { accountId, apiToken, accountHash } = credentials;\n const config: CfImagesConfig = { accountHash };\n // 호출 시점에 해석해야 테스트의 global fetch 모킹이 적용된다.\n const doFetch = (input: RequestInfo | URL, init?: RequestInit) =>\n (credentials.fetchImpl ?? globalThis.fetch)(input, init);\n\n async function withTimeout<T>(\n timeoutMs: number,\n run: (signal: AbortSignal) => Promise<T>,\n ): Promise<T> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n return await run(controller.signal);\n } finally {\n clearTimeout(timer);\n }\n }\n\n return {\n async uploadFromUrl(sourceUrl: string): Promise<string> {\n const form = new FormData();\n form.set(\"url\", sourceUrl);\n\n return withTimeout(UPLOAD_TIMEOUT_MS, async (signal) => {\n const res = await doFetch(\n `https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`,\n {\n method: \"POST\",\n headers: { authorization: `Bearer ${apiToken}` },\n body: form,\n signal,\n },\n );\n const json = (await res.json().catch(() => null)) as {\n success?: boolean;\n result?: { id?: string };\n errors?: Array<{ message?: string }>;\n } | null;\n if (!res.ok || !json?.success || !json.result?.id) {\n const msg =\n json?.errors?.map((e) => e.message).join(\"; \") ||\n `HTTP ${res.status}`;\n throw new Error(`CF Images 업로드 실패: ${msg}`);\n }\n return json.result.id;\n });\n },\n\n variantUrls(imageId: string) {\n return allVariantUrls(config, imageId);\n },\n\n async deleteImage(imageId: string): Promise<void> {\n await withTimeout(DELETE_TIMEOUT_MS, async (signal) => {\n const res = await doFetch(\n `https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1/${encodeURIComponent(imageId)}`,\n { method: \"DELETE\", headers: { authorization: `Bearer ${apiToken}` }, signal },\n );\n // 404 = 이미 없음 → idempotent 성공으로 간주.\n if (!res.ok && res.status !== 404) {\n throw new Error(`CF Images 삭제 실패: HTTP ${res.status}`);\n }\n });\n },\n };\n}\n\nexport interface ImageVariantsResult {\n /** CF Images delivery URL map. 미연결·실패 시 undefined. */\n variantsJson?: Record<ImageVariant, string>;\n /** `{ cfImageId }` — 삭제 시 orphan 정리에 쓴다. 미연결·실패 시 undefined. */\n metaJson?: { cfImageId: string };\n}\n\n/**\n * 업로드된 이미지의 variant/meta 를 만든다 — **모든 업로드 경로의 공통 헬퍼**.\n *\n * 이미지가 아니거나 CF Images 미연결(client=null)이면 graceful skip, CF 실패도\n * 삼켜서 빈 결과로 떨어뜨린다(미디어 등록 자체는 성공시킨다 — variant 없이도\n * 원본은 R2 에 있다). 업로드 경로가 늘어나도 결과 모양이 갈라지지 않도록\n * variant 채우기는 이 헬퍼를 통해서만 한다.\n */\nexport async function buildImageVariants(\n client: CfImagesClient | null,\n sourceUrl: string,\n onError?: (err: unknown) => void,\n): Promise<ImageVariantsResult> {\n if (!client) return {};\n try {\n const imageId = await client.uploadFromUrl(sourceUrl);\n return {\n variantsJson: client.variantUrls(imageId),\n metaJson: { cfImageId: imageId },\n };\n } catch (err) {\n onError?.(err);\n return {};\n }\n}\n","// ADR-0034 §7.1.1 test #4 — Korean filename normalization (한글 → hash)\n//\n// R2 object key 안전성: `safe` 는 **항상** 단일 path segment 로 안전하다 —\n// - 허용 문자: [a-zA-Z0-9._-] 만 (slash·공백·제어문자·CJK 불가)\n// - 디렉터리 성분(`/`·`\\`)·traversal(`..`)은 basename 추출 단계에서 제거\n// - 영숫자를 최소 1개 보장(dot-only `.`·`..` 방지), 길이 상한 적용\n// - 한글/CJK·비ASCII 는 base32(sha256) hash 로 변환, 확장자는 보존\n// 호출부가 `tenants/<id>/<category>/<safe>` 형태로 써도 prefix 를 벗어날 수 없다.\n\nimport { createHash } from \"node:crypto\";\n\n/** safe stem 에 허용되는 문자 — 단일 segment 안전 집합. */\nconst ASCII_SAFE_PATTERN = /^[a-zA-Z0-9._-]+$/;\n/** 유효한 확장자 — dot + 영숫자만 (예: \".jpg\", \".webp\"). slash/공백 섞이면 무효. */\nconst EXT_PATTERN = /^\\.[a-z0-9]+$/;\n/** 영숫자 1개 이상 포함 여부 — dot-only/빈 stem 방지용. */\nconst HAS_ALNUM = /[a-z0-9]/i;\n/** safe stem 길이 상한 (R2 key 길이 예산). 확장자는 별도. */\nconst MAX_STEM_LEN = 80;\nconst MAX_ALT_LEN = 120;\nconst MAX_SEO_STEM_LEN = 96;\n\nexport interface NormalizedFilename {\n /** R2 key segment 로 안전한 파일명 (확장자 포함). 항상 [a-zA-Z0-9._-]+. */\n safe: string;\n /** 원본 파일명 (UI 표시용, DB media.original_name) */\n original: string;\n /** 확장자 (소문자, dot 포함; e.g. \".jpg\"). 없으면 \"\". */\n ext: string;\n}\n\n/** 짧은 base32 hash (16자). UUID 와 충돌 회피용 별도 namespace. */\nfunction shortHash(input: string): string {\n return createHash(\"sha256\")\n .update(input)\n .digest(\"base64url\")\n .slice(0, 16)\n .replace(/[-_]/g, \"x\");\n}\n\n/** 디렉터리 성분 제거 — basename 만. `/`·`\\` 모두 분리(크로스 플랫폼 파일명 방어). */\nfunction basenameOf(filename: string): string {\n const cut = Math.max(filename.lastIndexOf(\"/\"), filename.lastIndexOf(\"\\\\\"));\n return cut >= 0 ? filename.slice(cut + 1) : filename;\n}\n\n/**\n * 확장자 추출 — basename 기준 마지막 dot 이후. dot+영숫자(EXT_PATTERN) 만 유효로\n * 인정하고, 그 외(slash·공백 포함 등)는 확장자 없음으로 처리한다.\n */\nfunction extractExt(basename: string): string {\n const dotIdx = basename.lastIndexOf(\".\");\n if (dotIdx <= 0 || dotIdx === basename.length - 1) return \"\";\n const ext = basename.slice(dotIdx).toLowerCase();\n return EXT_PATTERN.test(ext) ? ext : \"\";\n}\n\nfunction stemOfFilename(filename: string): string {\n const basename = basenameOf(filename);\n const ext = extractExt(basename);\n return ext ? basename.slice(0, basename.length - ext.length) : basename;\n}\n\n/**\n * 파일명 정규화. ASCII-safe(영숫자 포함) 이면 소문자화해 그대로, 아니면 hash.\n * 결과 `safe` 는 항상 단일 segment 로 안전(위 모듈 주석 불변식).\n */\nexport function normalizeFilename(original: string): NormalizedFilename {\n const basename = basenameOf(original);\n const ext = extractExt(basename);\n const stem = stemOfFilename(original);\n\n let safeStem: string;\n if (ASCII_SAFE_PATTERN.test(stem) && HAS_ALNUM.test(stem)) {\n safeStem = stem.toLowerCase().slice(0, MAX_STEM_LEN);\n } else {\n // 비ASCII·slash·공백·dot-only·빈 stem → hash (원본 기준 안정적 변환).\n safeStem = shortHash(stem || basename || original);\n }\n\n // 방어적 재확인 — 어떤 경로로도 영숫자 없는 stem 은 남기지 않는다.\n if (!HAS_ALNUM.test(safeStem)) {\n safeStem = shortHash(original);\n }\n\n return { safe: `${safeStem}${ext}`, original, ext };\n}\n\n/**\n * 파일명에서 SEO/accessibility 기본 alt 를 제안한다.\n *\n * 원본 파일명을 그대로 노출하지 않고, 확장자·경로·구분자·카메라/스크린샷\n * 잡음을 제거해 사람이 읽는 짧은 설명으로 바꾼다. 실제 문맥이 있는 surface\n * (예: 작성자 프로필)는 호출부가 더 구체적인 alt 로 override 할 수 있다.\n */\nexport function suggestAltFromFilename(original: string): string {\n let text = stemOfFilename(original)\n .normalize(\"NFKC\")\n .replace(/%[0-9a-f]{2}/gi, \" \")\n .replace(/[()[\\]{}]/g, \" \")\n .replace(/[._-]+/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n\n text = text\n .replace(/\\b(copy|final|edited|edit|export|upload)\\b/gi, \" \")\n .replace(/\\b(screenshot|screen shot|image|img|photo|picture|pic)\\b/gi, \" \")\n .replace(/\\b(dsc|dcim|pxl|kakaotalk|wechatimg)\\s*\\d+\\b/gi, \" \")\n .replace(/\\b\\d{3,4}\\s*x\\s*\\d{3,4}\\b/gi, \" \")\n .replace(/\\b(19|20)\\d{2}[ ._-]?(0[1-9]|1[0-2])[ ._-]?([0-2]\\d|3[01])\\b/g, \" \")\n .replace(/\\b\\d{4,}\\b/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n\n if (!text || !/[0-9A-Za-z가-힣一-龥ぁ-んァ-ン]/.test(text)) {\n return \"업로드 이미지\";\n }\n\n const capped = text.slice(0, MAX_ALT_LEN).trim();\n return capped.charAt(0).toLocaleUpperCase(\"ko-KR\") + capped.slice(1);\n}\n\n/**\n * 업로드 객체명에 붙일 SEO-friendly filename.\n *\n * URL 경로에 의미 있는 단어가 남도록 한글/CJK/라틴 문자와 숫자는 보존하고,\n * 나머지는 hyphen 으로 접는다. 호출부는 앞에 UUID 를 붙여 충돌을 피한다.\n */\nexport function seoFilenameFromFilename(\n original: string,\n fallbackExt = \"\",\n): string {\n const ext = extractExt(basenameOf(original)) || fallbackExt.toLowerCase();\n const alt = suggestAltFromFilename(original);\n const source = alt === \"업로드 이미지\" ? \"uploaded image\" : alt;\n const stem = source\n .normalize(\"NFKC\")\n .toLocaleLowerCase(\"ko-KR\")\n .replace(/[^\\p{L}\\p{N}]+/gu, \"-\")\n .replace(/-+/g, \"-\")\n .replace(/^-|-$/g, \"\")\n .slice(0, MAX_SEO_STEM_LEN)\n .replace(/-$/g, \"\");\n const safeStem = stem || \"uploaded-image\";\n return `${safeStem}${EXT_PATTERN.test(ext) ? ext : \"\"}`;\n}\n","// ADR-0034 §7.1.1 test #4 — R2 client wrapper (interface only, Phase 1 stub).\n//\n// Phase 1 environment-specific implementation:\n// - Cloudflare Workers/Pages = R2 binding (env.R2.put/get/delete)\n// - Vercel/Node = AWS SDK S3 client (S3-compatible API)\n//\n// 본 모듈은 interface + Korean filename + tenant prefix 강제 로직만.\n// 실 binding 은 tenant-admin 의 server action 또는 tenant-api 의 R2 handler 가 주입.\n\nimport { normalizeFilename } from \"./normalize-filename\";\n\n/** ADR-0016 multi-tenant 격리 의무 — r2_key prefix = `tenants/<tenant_id>/` */\nexport function r2KeyForTenant(\n tenantId: string,\n category: string,\n filename: string,\n): string {\n const { safe } = normalizeFilename(filename);\n return `tenants/${tenantId}/${category}/${safe}`;\n}\n\nexport interface R2PutOptions {\n contentType: string;\n cacheControl?: string;\n metadata?: Record<string, string>;\n}\n\nexport interface R2Object {\n key: string;\n size: number;\n etag: string;\n uploaded: Date;\n contentType?: string;\n metadata?: Record<string, string>;\n}\n\n/**\n * R2 client interface — environment-specific binding 이 본 interface 구현.\n * tenant-admin 가 server action 안에서 inject (DI).\n */\nexport interface R2Client {\n put(key: string, body: ArrayBuffer | Uint8Array, options: R2PutOptions): Promise<R2Object>;\n get(key: string): Promise<ArrayBuffer | null>;\n delete(key: string): Promise<void>;\n /** presigned URL — 외부 캐싱/CDN 용. Phase 1 = direct R2 public bucket */\n getPublicUrl(key: string): string;\n}\n\n/**\n * Tenant-aware R2 helper — key prefix 강제 + Korean filename normalize 자동.\n * tenant 외부에서 본 wrapper 우회하면 ESLint 의 no-raw-db-client 와 비슷한 rule\n * 추가 검토 (Phase 1b).\n */\nexport class TenantR2 {\n constructor(\n private readonly client: R2Client,\n private readonly tenantId: string,\n ) {}\n\n async uploadMedia(\n filename: string,\n body: ArrayBuffer | Uint8Array,\n contentType: string,\n ): Promise<{ key: string; publicUrl: string; object: R2Object }> {\n const key = r2KeyForTenant(this.tenantId, \"media\", filename);\n const object = await this.client.put(key, body, { contentType });\n return { key, publicUrl: this.client.getPublicUrl(key), object };\n }\n\n async deleteByKey(key: string): Promise<void> {\n if (!key.startsWith(`tenants/${this.tenantId}/`)) {\n throw new Error(\n `tenant 격리 위반: ${key} 는 tenants/${this.tenantId}/ prefix 와 일치하지 않음`,\n );\n }\n await this.client.delete(key);\n }\n}\n","// ADR-0034 §7.1.1 test #4 — AWS S3 SDK based R2Client 실 구현.\n//\n// Cloudflare R2 는 S3-compatible API 노출. @aws-sdk/client-s3 wrapper.\n// caller 가 endpoint + access key (Doppler) 주입.\n\nimport type { R2Client, R2Object, R2PutOptions } from \"./r2-client\";\n\nexport interface S3ClientConfig {\n endpoint: string; // e.g. https://<account>.r2.cloudflarestorage.com\n region?: string; // R2 = \"auto\"\n bucket: string;\n accessKeyId: string;\n secretAccessKey: string;\n publicBaseUrl: string; // CDN 또는 R2 public URL prefix\n}\n\n/**\n * AWS SDK 의존을 minimal — type-only declaration.\n * 실 binding 은 tenant-admin 의 server action 또는 tenant-api 가 SDK 주입.\n * 본 module 은 R2Client interface 의 S3-style 구현 스켈레톤.\n */\ninterface MinimalS3Sdk {\n send(command: unknown): Promise<{ ETag?: string; ContentLength?: number; Body?: AsyncIterable<Uint8Array> }>;\n}\n\ninterface S3PutObjectCommandInput {\n Bucket: string;\n Key: string;\n Body: Uint8Array;\n ContentType: string;\n CacheControl?: string;\n Metadata?: Record<string, string>;\n}\n\ninterface S3GetObjectCommandInput {\n Bucket: string;\n Key: string;\n}\n\ninterface S3DeleteObjectCommandInput {\n Bucket: string;\n Key: string;\n}\n\nexport interface S3CommandFactories {\n PutObjectCommand: new (input: S3PutObjectCommandInput) => unknown;\n GetObjectCommand: new (input: S3GetObjectCommandInput) => unknown;\n DeleteObjectCommand: new (input: S3DeleteObjectCommandInput) => unknown;\n}\n\n/**\n * R2Client 구현 (S3 SDK based). caller 가 SDK + commands 주입.\n *\n * 사용 예시 (tenant-admin):\n * import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand }\n * from \"@aws-sdk/client-s3\";\n * const r2 = new S3R2Client(\n * new S3Client({ endpoint, region: \"auto\", credentials: {...} }),\n * { PutObjectCommand, GetObjectCommand, DeleteObjectCommand },\n * config,\n * );\n */\nexport class S3R2Client implements R2Client {\n constructor(\n private readonly sdk: MinimalS3Sdk,\n private readonly commands: S3CommandFactories,\n private readonly config: S3ClientConfig,\n ) {}\n\n async put(\n key: string,\n body: ArrayBuffer | Uint8Array,\n options: R2PutOptions,\n ): Promise<R2Object> {\n const buf = body instanceof Uint8Array ? body : new Uint8Array(body);\n const cmd = new this.commands.PutObjectCommand({\n Bucket: this.config.bucket,\n Key: key,\n Body: buf,\n ContentType: options.contentType,\n CacheControl: options.cacheControl,\n Metadata: options.metadata,\n });\n const result = await this.sdk.send(cmd);\n return {\n key,\n size: buf.byteLength,\n etag: result.ETag ?? \"\",\n uploaded: new Date(),\n contentType: options.contentType,\n metadata: options.metadata,\n };\n }\n\n async get(key: string): Promise<ArrayBuffer | null> {\n const cmd = new this.commands.GetObjectCommand({\n Bucket: this.config.bucket,\n Key: key,\n });\n try {\n const result = await this.sdk.send(cmd);\n if (!result.Body) return null;\n const chunks: Uint8Array[] = [];\n for await (const chunk of result.Body) {\n chunks.push(chunk);\n }\n const total = chunks.reduce((a, c) => a + c.byteLength, 0);\n const merged = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n return merged.buffer;\n } catch (_err) {\n return null;\n }\n }\n\n async delete(key: string): Promise<void> {\n const cmd = new this.commands.DeleteObjectCommand({\n Bucket: this.config.bucket,\n Key: key,\n });\n await this.sdk.send(cmd);\n }\n\n getPublicUrl(key: string): string {\n const base = this.config.publicBaseUrl.replace(/\\/$/, \"\");\n return `${base}/${key}`;\n }\n}\n","/** Online archive stores only GPG ciphertext; applications never receive backup credentials. */\nexport interface MediaArchiveConfig {\n baseUrl: string;\n token: string;\n fetchImpl?: typeof fetch;\n sleep?: (ms: number) => Promise<void>;\n}\n\nexport interface ArchiveReceipt {\n version: 1;\n tenantId: string;\n sha256: string;\n sizeBytes: number;\n archiveKey: string;\n cipherSha256: string;\n metadataKey: string;\n metadataCipherSha256: string;\n verifiedAt: string;\n}\n\nexport interface PreserveMediaInput {\n tenantId: string;\n sourceKey: string;\n sourceBucket: string;\n contentType: string;\n bytes: ArrayBuffer;\n uploadId?: string;\n}\n\nexport interface MediaTombstoneInput {\n tenantId: string;\n sourceKey: string;\n sourceBucket: string;\n receipt: ArchiveReceipt;\n deletedAt: string;\n metadata: Record<string, unknown>;\n}\n\nexport interface MediaArchiveClient {\n preserve(input: PreserveMediaInput): Promise<ArchiveReceipt>;\n verify(receipt: ArchiveReceipt): Promise<ArchiveReceipt>;\n findSource(input: { tenantId: string; sourceBucket: string; sourceKey: string }): Promise<ArchiveReceipt | null>;\n tombstone(input: MediaTombstoneInput): Promise<void>;\n}\n\nexport const ARCHIVE_FAILURE_REASONS = [\"missing_receipt\", \"checksum_mismatch\", \"invalid_receipt\", \"auth\", \"throttled\", \"timeout\", \"transport\", \"busy\", \"unavailable\"] as const;\nexport type ArchiveFailureReason = typeof ARCHIVE_FAILURE_REASONS[number];\nexport class MediaPreservationError extends Error {\n constructor(message = \"파일 보존을 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.\",\n readonly reason: ArchiveFailureReason = \"invalid_receipt\", readonly status?: number) {\n super(message);\n this.name = \"MediaPreservationError\";\n }\n}\n\n/** Organization support attachments have their own owner namespace, never a fake tenant. */\nexport function archiveOwnerPrefix(scopeId: string): string {\n if (/^org-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(scopeId)) {\n return `organizations/${scopeId.slice(4)}/`;\n }\n if (!/^[A-Za-z0-9_-]+$/.test(scopeId) || scopeId.startsWith(\"org-\")) throw new MediaPreservationError();\n return `tenants/${scopeId}/`;\n}\n\nexport function assertTenantObjectKey(tenantId: string, key: string): void {\n if (!/^[A-Za-z0-9_-]+$/.test(tenantId) ||\n !(key.startsWith(archiveOwnerPrefix(tenantId)) ||\n (tenantId.startsWith(\"org-\") && key.startsWith(`support/${tenantId.slice(4)}/`)) ||\n (!tenantId.startsWith(\"org-\") && key.startsWith(`${tenantId}/medical/records/`))) ||\n key.split(\"/\").some((part) => !part || part === \".\" || part === \"..\") ||\n /[\\\\\\x00-\\x1f\\x7f]/.test(key)) {\n throw new MediaPreservationError(\"잘못된 파일 소유 경로입니다.\");\n }\n}\n\nexport async function mediaSha256(bytes: ArrayBuffer): Promise<string> {\n const digest = new Uint8Array(await crypto.subtle.digest(\"SHA-256\", bytes));\n return Array.from(digest, (byte) => byte.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nfunction metadataHeader(value: unknown): string {\n const bytes = new TextEncoder().encode(JSON.stringify(value));\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nfunction readReceipt(value: unknown, tenantId: string, sha256: string, sizeBytes: number): ArchiveReceipt {\n if (!/^[a-f0-9]{64}$/.test(sha256) || !Number.isSafeInteger(sizeBytes) || sizeBytes < 0 ||\n !value || typeof value !== \"object\") throw new MediaPreservationError();\n const item = value as Record<string, unknown>;\n if (item.version !== 1 || item.tenantId !== tenantId || item.sha256 !== sha256 ||\n item.sizeBytes !== sizeBytes ||\n typeof item.archiveKey !== \"string\" ||\n !new RegExp(`^${archiveOwnerPrefix(tenantId)}media-archive/${sha256}/[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/original\\\\.gpg$`).test(item.archiveKey) ||\n typeof item.cipherSha256 !== \"string\" || !/^[a-f0-9]{64}$/.test(item.cipherSha256) ||\n item.metadataKey !== item.archiveKey.replace(/original\\.gpg$/, \"metadata.gpg\") ||\n typeof item.metadataCipherSha256 !== \"string\" || !/^[a-f0-9]{64}$/.test(item.metadataCipherSha256) ||\n typeof item.verifiedAt !== \"string\" || !Number.isFinite(Date.parse(item.verifiedAt))) {\n throw new MediaPreservationError();\n }\n return { version: 1, tenantId, sha256, sizeBytes, archiveKey: item.archiveKey,\n cipherSha256: item.cipherSha256, metadataKey: item.metadataKey,\n metadataCipherSha256: item.metadataCipherSha256, verifiedAt: item.verifiedAt };\n}\n\nexport function createMediaArchiveClient(config: MediaArchiveConfig): MediaArchiveClient {\n const base = new URL(config.baseUrl);\n if (base.protocol !== \"https:\" || base.username || base.password || !config.token.trim()) {\n throw new MediaPreservationError(\"파일 보존 연결 설정을 확인해 주세요.\");\n }\n const fetchImpl = config.fetchImpl ?? fetch;\n const sleep = config.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));\n async function request(path: string, init: RequestInit, allowMissing = false): Promise<unknown> {\n const started = Date.now();\n for (let attempt = 1; ; attempt++) {\n let retryAfterMs = 0;\n try {\n const headers = new Headers(init.headers);\n headers.set(\"Authorization\", `Bearer ${config.token}`);\n const response = await fetchImpl(new URL(path, base), {\n // Workers reject redirect:\"error\"; manual keeps credentials on this origin.\n ...init, headers, redirect: \"manual\", signal: AbortSignal.timeout(init.method === \"GET\" ? 10_000 : 60_000),\n });\n if (response.status === 404 && allowMissing) { await response.body?.cancel(); return null; }\n if (!response.ok) {\n const rawReason = response.headers.get(\"x-archive-failure\");\n const reason = ARCHIVE_FAILURE_REASONS.find((value) => value === rawReason)\n ?? ([401, 403].includes(response.status) ? \"auth\" : response.status === 429 ? \"throttled\"\n : [408, 500, 502, 503, 504].includes(response.status) ? \"unavailable\" : \"invalid_receipt\");\n const retryAfter = response.headers.get(\"retry-after\");\n retryAfterMs = retryAfter && /^\\d+$/.test(retryAfter) ? Number(retryAfter) * 1000\n : retryAfter && Number.isFinite(Date.parse(retryAfter)) ? Math.max(0, Date.parse(retryAfter) - Date.now()) : 0;\n await response.body?.cancel();\n throw new MediaPreservationError(undefined, reason, response.status);\n }\n return await response.json();\n } catch (error) {\n // Gateway failures must not leak a URL, token, or source metadata to client logs.\n const safe = error instanceof MediaPreservationError ? error : new MediaPreservationError(undefined,\n error instanceof Error && [\"TimeoutError\", \"AbortError\"].includes(error.name) ? \"timeout\"\n : error instanceof SyntaxError ? \"invalid_receipt\" : \"transport\");\n const delay = Math.max(retryAfterMs, 250 * 2 ** (attempt - 1) + Math.random() * 250);\n if (init.method !== \"GET\" || attempt >= 3 || ![\"transport\", \"timeout\", \"throttled\", \"busy\", \"unavailable\"].includes(safe.reason)\n || Date.now() - started + delay + 10_000 > 35_000) throw safe;\n await sleep(delay);\n }\n }\n }\n return {\n async preserve(input) {\n assertTenantObjectKey(input.tenantId, input.sourceKey);\n const sha256 = await mediaSha256(input.bytes);\n const value = await request(`/v1/objects/${input.tenantId}/${sha256}`, {\n method: \"PUT\",\n headers: { \"Content-Type\": input.contentType, \"X-Archive-Metadata\": metadataHeader({\n sourceKey: input.sourceKey, sourceBucket: input.sourceBucket, contentType: input.contentType, uploadId: input.uploadId,\n }) },\n body: input.bytes,\n });\n return readReceipt(value, input.tenantId, sha256, input.bytes.byteLength);\n },\n async findSource(input) {\n assertTenantObjectKey(input.tenantId, input.sourceKey);\n const hash = await mediaSha256(new TextEncoder().encode(`${input.sourceBucket}\\n${input.sourceKey}`).buffer);\n const value = await request(`/v1/sources/${input.tenantId}/${hash}`, { method: \"GET\" }, true);\n if (value === null) return null;\n if (!value || typeof value !== \"object\" || !(\"sha256\" in value) || typeof value.sha256 !== \"string\" ||\n !/^[a-f0-9]{64}$/.test(value.sha256) || !(\"sizeBytes\" in value) || typeof value.sizeBytes !== \"number\") {\n throw new MediaPreservationError();\n }\n return readReceipt(value, input.tenantId, value.sha256, value.sizeBytes);\n },\n async verify(receipt) {\n readReceipt(receipt, receipt.tenantId, receipt.sha256, receipt.sizeBytes);\n const value = await request(`/v1/objects/${receipt.tenantId}/${receipt.sha256}`, { method: \"GET\" });\n const verified = readReceipt(value, receipt.tenantId, receipt.sha256, receipt.sizeBytes);\n if (verified.cipherSha256 !== receipt.cipherSha256 || verified.metadataKey !== receipt.metadataKey ||\n verified.metadataCipherSha256 !== receipt.metadataCipherSha256) throw new MediaPreservationError();\n return verified;\n },\n async tombstone(input) {\n assertTenantObjectKey(input.tenantId, input.sourceKey);\n if (input.receipt.tenantId !== input.tenantId) throw new MediaPreservationError();\n const value = await request(\"/v1/tombstones\", {\n method: \"POST\", headers: { \"Content-Type\": \"application/json\" }, body: JSON.stringify(input),\n });\n if (!value || typeof value !== \"object\" || !(\"recorded\" in value) || value.recorded !== true ||\n !(\"tombstoneKey\" in value) || typeof value.tombstoneKey !== \"string\" ||\n !value.tombstoneKey.startsWith(archiveOwnerPrefix(input.tenantId))) throw new MediaPreservationError();\n },\n };\n}\n\nexport interface PreservationStorage {\n get(key: string): Promise<{ bytes: ArrayBuffer; contentType: string } | null>;\n /** Conditional create only. Existing keys must never be overwritten. */\n putIfAbsent(key: string, bytes: ArrayBuffer, contentType: string): Promise<void>;\n}\n\nexport interface FinalizePreservedUploadInput {\n tenantId: string;\n siteId: string;\n stagingKey: string;\n expectedContentType: string;\n expectedSize: number;\n sourceBucket: string;\n storage: PreservationStorage;\n archive: MediaArchiveClient;\n}\n\n/** Staging upload ID is the media primary key, so retries cannot insert duplicate rows. */\nexport function mediaUploadId(stagingKey: string): string {\n const match = /\\/staging\\/([0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})[.-]/.exec(stagingKey);\n if (!match?.[1]) throw new MediaPreservationError(\"잘못된 업로드 경로입니다.\");\n return match[1];\n}\n\nexport async function finalizePreservedUpload(input: FinalizePreservedUploadInput): Promise<{\n id: string; key: string; receipt: ArchiveReceipt;\n}> {\n assertTenantObjectKey(input.tenantId, input.stagingKey);\n const id = mediaUploadId(input.stagingKey);\n const original = await input.storage.get(input.stagingKey);\n if (!original || original.bytes.byteLength !== input.expectedSize ||\n original.contentType.split(\";\", 1)[0]?.trim().toLowerCase() !== input.expectedContentType) {\n throw new MediaPreservationError(\"업로드된 파일의 형식과 크기를 확인해 주세요.\");\n }\n const sha256 = await mediaSha256(original.bytes);\n const filename = input.stagingKey.slice(input.stagingKey.lastIndexOf(\"/\") + 1);\n const key = `tenants/${input.tenantId}/sites/${input.siteId}/media/originals/${sha256}/${filename}`;\n await input.storage.putIfAbsent(key, original.bytes, input.expectedContentType);\n const final = await input.storage.get(key);\n if (!final || await mediaSha256(final.bytes) !== sha256) throw new MediaPreservationError();\n const receipt = await input.archive.preserve({ tenantId: input.tenantId, sourceBucket: input.sourceBucket,\n sourceKey: key, contentType: input.expectedContentType, bytes: final.bytes, uploadId: id });\n return { id, key, receipt };\n}\n\n/** Snapshot bytes once; later source mutations cannot change the encrypted copy. */\nexport async function preserveStoredObject(input: {\n tenantId: string; sourceBucket: string; sourceKey: string;\n storage: Pick<PreservationStorage, \"get\">; archive: MediaArchiveClient;\n}): Promise<ArchiveReceipt> {\n assertTenantObjectKey(input.tenantId, input.sourceKey);\n const original = await input.storage.get(input.sourceKey);\n if (!original) {\n const existing = await input.archive.findSource(input);\n if (!existing) throw new MediaPreservationError(\"보존할 원본 파일을 찾을 수 없습니다.\");\n return input.archive.verify(existing);\n }\n return input.archive.preserve({ tenantId: input.tenantId, sourceBucket: input.sourceBucket,\n sourceKey: input.sourceKey, ...original });\n}\n","import {\n assertTenantObjectKey,\n mediaSha256,\n MediaPreservationError,\n type ArchiveReceipt,\n type MediaArchiveClient,\n type PreservationStorage,\n} from \"./archive\";\n\n/** Caller authorizes the staging locator; only the new tenant-owned key can be archived. */\nexport async function preserveStoredUpload(input: {\n tenantId: string;\n sourceKey: string;\n sourceBucket: string;\n expectedSize: number;\n allowedContentTypes: readonly string[];\n finalKey: (sha256: string) => string;\n storage: PreservationStorage;\n archive: MediaArchiveClient;\n}): Promise<{ key: string; receipt: ArchiveReceipt }> {\n const original = await input.storage.get(input.sourceKey);\n const contentType = original?.contentType.split(\";\", 1)[0]?.trim().toLowerCase();\n if (!original || original.bytes.byteLength !== input.expectedSize || !contentType ||\n !input.allowedContentTypes.includes(contentType)) {\n throw new MediaPreservationError(\"업로드된 파일의 형식과 크기를 확인해 주세요.\");\n }\n const sha256 = await mediaSha256(original.bytes);\n const key = input.finalKey(sha256);\n assertTenantObjectKey(input.tenantId, key);\n if (key === input.sourceKey) throw new MediaPreservationError(\"보존 파일은 별도 경로가 필요합니다.\");\n await input.storage.putIfAbsent(key, original.bytes, contentType);\n const stored = await input.storage.get(key);\n if (!stored || await mediaSha256(stored.bytes) !== sha256 ||\n stored.contentType.split(\";\", 1)[0]?.trim().toLowerCase() !== contentType) {\n throw new MediaPreservationError();\n }\n const receipt = await input.archive.preserve({\n tenantId: input.tenantId,\n sourceKey: key,\n sourceBucket: input.sourceBucket,\n contentType,\n bytes: stored.bytes,\n });\n return { key, receipt };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/variants.ts","../src/cf-images.ts","../src/normalize-filename.ts","../src/r2-client.ts","../src/s3-client.ts","../src/archive-evidence.ts","../src/archive.ts","../src/preserve-object.ts"],"sourcesContent":["// ADR-0034 §7.1.1 test #4 — CF Images variant 4종 URL 생성기.\n//\n// Phase 1 = thumbnail/sm/md/lg. CF Images delivery URL pattern:\n// https://imagedelivery.net/<account-hash>/<image-id>/<variant>\n\nexport type ImageVariant = \"thumbnail\" | \"sm\" | \"md\" | \"lg\";\n\nexport const VARIANT_NAMES: readonly ImageVariant[] = [\n \"thumbnail\",\n \"sm\",\n \"md\",\n \"lg\",\n] as const;\n\nexport interface VariantSizes {\n thumbnail: { width: 96; height: 96 };\n sm: { width: 320; height: 320 };\n md: { width: 768; height: 768 };\n lg: { width: 1600; height: 1600 };\n}\n\nexport const VARIANT_SIZES: VariantSizes = {\n thumbnail: { width: 96, height: 96 },\n sm: { width: 320, height: 320 },\n md: { width: 768, height: 768 },\n lg: { width: 1600, height: 1600 },\n};\n\nexport interface CfImagesConfig {\n accountHash: string;\n}\n\n/** CF Images delivery URL 생성. */\nexport function variantUrl(\n config: CfImagesConfig,\n imageId: string,\n variant: ImageVariant,\n): string {\n return `https://imagedelivery.net/${config.accountHash}/${imageId}/${variant}`;\n}\n\n/** 모든 variant 의 URL map. */\nexport function allVariantUrls(\n config: CfImagesConfig,\n imageId: string,\n): Record<ImageVariant, string> {\n return Object.fromEntries(\n VARIANT_NAMES.map((v) => [v, variantUrl(config, imageId, v)]),\n ) as Record<ImageVariant, string>;\n}\n\n// ── delivery URL 의 variant 세그먼트 교체 헬퍼 (account-hash/image-id 를 몰라도\n// 이미 받은 full delivery URL 만으로 variant 를 바꾼다. CfImage 렌더용.) ──\n\nconst CF_HOST = \"imagedelivery.net\";\nconst KNOWN_VARIANT_SUFFIX =\n /\\/(public|lg|md|sm|thumbnail|jhwebp|jhfull|jhthumb|jhmobile)$/;\n\n/** imagedelivery delivery URL 여부. */\nexport function isCfImageUrl(url: string): boolean {\n return typeof url === \"string\" && url.includes(CF_HOST);\n}\n\n/**\n * delivery URL 의 끝 variant 세그먼트를 교체. 세그먼트가 없으면 덧붙인다.\n * CF URL 이 아니면 원본 그대로 반환(외부/로컬 URL 안전 통과).\n */\nexport function cfImageVariant(\n url: string,\n variant: ImageVariant | \"public\",\n): string {\n if (!isCfImageUrl(url)) return url;\n return KNOWN_VARIANT_SUFFIX.test(url)\n ? url.replace(KNOWN_VARIANT_SUFFIX, `/${variant}`)\n : `${url}/${variant}`;\n}\n\n/** sm/md/lg srcset 문자열(CF URL 일 때만, 아니면 undefined). 폭은 VARIANT_SIZES 기준. */\nexport function cfImageSrcSet(\n url: string,\n variants: readonly ImageVariant[] = [\"sm\", \"md\", \"lg\"],\n): string | undefined {\n if (!isCfImageUrl(url)) return undefined;\n return variants\n .map((v) => `${cfImageVariant(url, v)} ${VARIANT_SIZES[v].width}w`)\n .join(\", \");\n}\n","// CF Images 업로드/삭제 seam (ADR-0034 §7.1.1 test #4) — variants.ts 가 delivery\n// URL 을 *조립*만 하던 것을, 실제 CF Images 업로드(→ image id)로 뒷받침한다.\n//\n// **이 파일이 단일 seam이다.** 예전엔 이 구현이 tenant-admin 안에만 있어서, 같은 일을\n// 하는 REST 업로드 경로(api-core `/v1/cms/media/uploads/complete`)가 CF Images 단계를\n// 통째로 빠뜨렸다. 그 결과 REST 로 올린 미디어는 `variants_json` 이 `{}` 로 남고,\n// 공개 posts API 는 featured 이미지를 variants_json 에서만 해석하므로\n// `featured_image_url` 이 영원히 null 이었다 (2026-07-26 발견 · 업로드·글 생성이\n// 모두 201 로 성공해 화면에서만 이미지가 빠지는 조용한 실패였다).\n//\n// 새 소비자는 반드시 이 팩토리를 쓸 것. 자격증명은 주입받는다 — Workers(env 바인딩)와\n// Next.js(process.env) 양쪽에서 같은 코드가 돌아야 하므로 여기서 env 를 읽지 않는다.\n\nimport { allVariantUrls, type CfImagesConfig, type ImageVariant } from \"./variants\";\n\n/** CF 가 sourceUrl 을 서버측에서 fetch → 보통 빠르나 큰 이미지 대비 넉넉히. */\nconst UPLOAD_TIMEOUT_MS = 15_000;\nconst DELETE_TIMEOUT_MS = 10_000;\n\nexport interface CfImagesCredentials {\n /** Cloudflare account id. */\n accountId: string;\n /** Images 쓰기 권한 토큰. */\n apiToken: string;\n /** imagedelivery.net account hash. */\n accountHash: string;\n /** 테스트/워커 주입용. 미지정 시 전역 fetch. */\n fetchImpl?: typeof fetch;\n}\n\nexport interface CfImagesClient {\n /**\n * R2 public URL 의 이미지를 CF Images 로 \"upload via URL\" 한다.\n * @returns CF Images image id. 실패 시 throw (호출부가 명확히 인지).\n */\n uploadFromUrl(sourceUrl: string): Promise<string>;\n /** image id → 4 variant(thumbnail/sm/md/lg) delivery URL map. */\n variantUrls(imageId: string): Record<ImageVariant, string>;\n /** CF Images 이미지 삭제 (orphan 방지). 실패 시 throw — 호출부가 best-effort 처리. */\n deleteImage(imageId: string): Promise<void>;\n}\n\n/**\n * CF Images 클라이언트. 자격증명 3종을 모두 받아야 만들 수 있다 —\n * 미설정 판단(graceful skip)은 호출부 책임이다.\n */\nexport function createCfImagesClient(\n credentials: CfImagesCredentials,\n): CfImagesClient {\n const { accountId, apiToken, accountHash } = credentials;\n const config: CfImagesConfig = { accountHash };\n // 호출 시점에 해석해야 테스트의 global fetch 모킹이 적용된다.\n const doFetch = (input: RequestInfo | URL, init?: RequestInit) =>\n (credentials.fetchImpl ?? globalThis.fetch)(input, init);\n\n async function withTimeout<T>(\n timeoutMs: number,\n run: (signal: AbortSignal) => Promise<T>,\n ): Promise<T> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n return await run(controller.signal);\n } finally {\n clearTimeout(timer);\n }\n }\n\n return {\n async uploadFromUrl(sourceUrl: string): Promise<string> {\n const form = new FormData();\n form.set(\"url\", sourceUrl);\n\n return withTimeout(UPLOAD_TIMEOUT_MS, async (signal) => {\n const res = await doFetch(\n `https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`,\n {\n method: \"POST\",\n headers: { authorization: `Bearer ${apiToken}` },\n body: form,\n signal,\n },\n );\n const json = (await res.json().catch(() => null)) as {\n success?: boolean;\n result?: { id?: string };\n errors?: Array<{ message?: string }>;\n } | null;\n if (!res.ok || !json?.success || !json.result?.id) {\n const msg =\n json?.errors?.map((e) => e.message).join(\"; \") ||\n `HTTP ${res.status}`;\n throw new Error(`CF Images 업로드 실패: ${msg}`);\n }\n return json.result.id;\n });\n },\n\n variantUrls(imageId: string) {\n return allVariantUrls(config, imageId);\n },\n\n async deleteImage(imageId: string): Promise<void> {\n await withTimeout(DELETE_TIMEOUT_MS, async (signal) => {\n const res = await doFetch(\n `https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1/${encodeURIComponent(imageId)}`,\n { method: \"DELETE\", headers: { authorization: `Bearer ${apiToken}` }, signal },\n );\n // 404 = 이미 없음 → idempotent 성공으로 간주.\n if (!res.ok && res.status !== 404) {\n throw new Error(`CF Images 삭제 실패: HTTP ${res.status}`);\n }\n });\n },\n };\n}\n\nexport interface ImageVariantsResult {\n /** CF Images delivery URL map. 미연결·실패 시 undefined. */\n variantsJson?: Record<ImageVariant, string>;\n /** `{ cfImageId }` — 삭제 시 orphan 정리에 쓴다. 미연결·실패 시 undefined. */\n metaJson?: { cfImageId: string };\n}\n\n/**\n * 업로드된 이미지의 variant/meta 를 만든다 — **모든 업로드 경로의 공통 헬퍼**.\n *\n * 이미지가 아니거나 CF Images 미연결(client=null)이면 graceful skip, CF 실패도\n * 삼켜서 빈 결과로 떨어뜨린다(미디어 등록 자체는 성공시킨다 — variant 없이도\n * 원본은 R2 에 있다). 업로드 경로가 늘어나도 결과 모양이 갈라지지 않도록\n * variant 채우기는 이 헬퍼를 통해서만 한다.\n */\nexport async function buildImageVariants(\n client: CfImagesClient | null,\n sourceUrl: string,\n onError?: (err: unknown) => void,\n): Promise<ImageVariantsResult> {\n if (!client) return {};\n try {\n const imageId = await client.uploadFromUrl(sourceUrl);\n return {\n variantsJson: client.variantUrls(imageId),\n metaJson: { cfImageId: imageId },\n };\n } catch (err) {\n onError?.(err);\n return {};\n }\n}\n","// ADR-0034 §7.1.1 test #4 — Korean filename normalization (한글 → hash)\n//\n// R2 object key 안전성: `safe` 는 **항상** 단일 path segment 로 안전하다 —\n// - 허용 문자: [a-zA-Z0-9._-] 만 (slash·공백·제어문자·CJK 불가)\n// - 디렉터리 성분(`/`·`\\`)·traversal(`..`)은 basename 추출 단계에서 제거\n// - 영숫자를 최소 1개 보장(dot-only `.`·`..` 방지), 길이 상한 적용\n// - 한글/CJK·비ASCII 는 base32(sha256) hash 로 변환, 확장자는 보존\n// 호출부가 `tenants/<id>/<category>/<safe>` 형태로 써도 prefix 를 벗어날 수 없다.\n\nimport { createHash } from \"node:crypto\";\n\n/** safe stem 에 허용되는 문자 — 단일 segment 안전 집합. */\nconst ASCII_SAFE_PATTERN = /^[a-zA-Z0-9._-]+$/;\n/** 유효한 확장자 — dot + 영숫자만 (예: \".jpg\", \".webp\"). slash/공백 섞이면 무효. */\nconst EXT_PATTERN = /^\\.[a-z0-9]+$/;\n/** 영숫자 1개 이상 포함 여부 — dot-only/빈 stem 방지용. */\nconst HAS_ALNUM = /[a-z0-9]/i;\n/** safe stem 길이 상한 (R2 key 길이 예산). 확장자는 별도. */\nconst MAX_STEM_LEN = 80;\nconst MAX_ALT_LEN = 120;\nconst MAX_SEO_STEM_LEN = 96;\n\nexport interface NormalizedFilename {\n /** R2 key segment 로 안전한 파일명 (확장자 포함). 항상 [a-zA-Z0-9._-]+. */\n safe: string;\n /** 원본 파일명 (UI 표시용, DB media.original_name) */\n original: string;\n /** 확장자 (소문자, dot 포함; e.g. \".jpg\"). 없으면 \"\". */\n ext: string;\n}\n\n/** 짧은 base32 hash (16자). UUID 와 충돌 회피용 별도 namespace. */\nfunction shortHash(input: string): string {\n return createHash(\"sha256\")\n .update(input)\n .digest(\"base64url\")\n .slice(0, 16)\n .replace(/[-_]/g, \"x\");\n}\n\n/** 디렉터리 성분 제거 — basename 만. `/`·`\\` 모두 분리(크로스 플랫폼 파일명 방어). */\nfunction basenameOf(filename: string): string {\n const cut = Math.max(filename.lastIndexOf(\"/\"), filename.lastIndexOf(\"\\\\\"));\n return cut >= 0 ? filename.slice(cut + 1) : filename;\n}\n\n/**\n * 확장자 추출 — basename 기준 마지막 dot 이후. dot+영숫자(EXT_PATTERN) 만 유효로\n * 인정하고, 그 외(slash·공백 포함 등)는 확장자 없음으로 처리한다.\n */\nfunction extractExt(basename: string): string {\n const dotIdx = basename.lastIndexOf(\".\");\n if (dotIdx <= 0 || dotIdx === basename.length - 1) return \"\";\n const ext = basename.slice(dotIdx).toLowerCase();\n return EXT_PATTERN.test(ext) ? ext : \"\";\n}\n\nfunction stemOfFilename(filename: string): string {\n const basename = basenameOf(filename);\n const ext = extractExt(basename);\n return ext ? basename.slice(0, basename.length - ext.length) : basename;\n}\n\n/**\n * 파일명 정규화. ASCII-safe(영숫자 포함) 이면 소문자화해 그대로, 아니면 hash.\n * 결과 `safe` 는 항상 단일 segment 로 안전(위 모듈 주석 불변식).\n */\nexport function normalizeFilename(original: string): NormalizedFilename {\n const basename = basenameOf(original);\n const ext = extractExt(basename);\n const stem = stemOfFilename(original);\n\n let safeStem: string;\n if (ASCII_SAFE_PATTERN.test(stem) && HAS_ALNUM.test(stem)) {\n safeStem = stem.toLowerCase().slice(0, MAX_STEM_LEN);\n } else {\n // 비ASCII·slash·공백·dot-only·빈 stem → hash (원본 기준 안정적 변환).\n safeStem = shortHash(stem || basename || original);\n }\n\n // 방어적 재확인 — 어떤 경로로도 영숫자 없는 stem 은 남기지 않는다.\n if (!HAS_ALNUM.test(safeStem)) {\n safeStem = shortHash(original);\n }\n\n return { safe: `${safeStem}${ext}`, original, ext };\n}\n\n/**\n * 파일명에서 SEO/accessibility 기본 alt 를 제안한다.\n *\n * 원본 파일명을 그대로 노출하지 않고, 확장자·경로·구분자·카메라/스크린샷\n * 잡음을 제거해 사람이 읽는 짧은 설명으로 바꾼다. 실제 문맥이 있는 surface\n * (예: 작성자 프로필)는 호출부가 더 구체적인 alt 로 override 할 수 있다.\n */\nexport function suggestAltFromFilename(original: string): string {\n let text = stemOfFilename(original)\n .normalize(\"NFKC\")\n .replace(/%[0-9a-f]{2}/gi, \" \")\n .replace(/[()[\\]{}]/g, \" \")\n .replace(/[._-]+/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n\n text = text\n .replace(/\\b(copy|final|edited|edit|export|upload)\\b/gi, \" \")\n .replace(/\\b(screenshot|screen shot|image|img|photo|picture|pic)\\b/gi, \" \")\n .replace(/\\b(dsc|dcim|pxl|kakaotalk|wechatimg)\\s*\\d+\\b/gi, \" \")\n .replace(/\\b\\d{3,4}\\s*x\\s*\\d{3,4}\\b/gi, \" \")\n .replace(/\\b(19|20)\\d{2}[ ._-]?(0[1-9]|1[0-2])[ ._-]?([0-2]\\d|3[01])\\b/g, \" \")\n .replace(/\\b\\d{4,}\\b/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n\n if (!text || !/[0-9A-Za-z가-힣一-龥ぁ-んァ-ン]/.test(text)) {\n return \"업로드 이미지\";\n }\n\n const capped = text.slice(0, MAX_ALT_LEN).trim();\n return capped.charAt(0).toLocaleUpperCase(\"ko-KR\") + capped.slice(1);\n}\n\n/**\n * 업로드 객체명에 붙일 SEO-friendly filename.\n *\n * URL 경로에 의미 있는 단어가 남도록 한글/CJK/라틴 문자와 숫자는 보존하고,\n * 나머지는 hyphen 으로 접는다. 호출부는 앞에 UUID 를 붙여 충돌을 피한다.\n */\nexport function seoFilenameFromFilename(\n original: string,\n fallbackExt = \"\",\n): string {\n const ext = extractExt(basenameOf(original)) || fallbackExt.toLowerCase();\n const alt = suggestAltFromFilename(original);\n const source = alt === \"업로드 이미지\" ? \"uploaded image\" : alt;\n const stem = source\n .normalize(\"NFKC\")\n .toLocaleLowerCase(\"ko-KR\")\n .replace(/[^\\p{L}\\p{N}]+/gu, \"-\")\n .replace(/-+/g, \"-\")\n .replace(/^-|-$/g, \"\")\n .slice(0, MAX_SEO_STEM_LEN)\n .replace(/-$/g, \"\");\n const safeStem = stem || \"uploaded-image\";\n return `${safeStem}${EXT_PATTERN.test(ext) ? ext : \"\"}`;\n}\n","// ADR-0034 §7.1.1 test #4 — R2 client wrapper (interface only, Phase 1 stub).\n//\n// Phase 1 environment-specific implementation:\n// - Cloudflare Workers/Pages = R2 binding (env.R2.put/get/delete)\n// - Vercel/Node = AWS SDK S3 client (S3-compatible API)\n//\n// 본 모듈은 interface + Korean filename + tenant prefix 강제 로직만.\n// 실 binding 은 tenant-admin 의 server action 또는 tenant-api 의 R2 handler 가 주입.\n\nimport { normalizeFilename } from \"./normalize-filename\";\n\n/** ADR-0016 multi-tenant 격리 의무 — r2_key prefix = `tenants/<tenant_id>/` */\nexport function r2KeyForTenant(\n tenantId: string,\n category: string,\n filename: string,\n): string {\n const { safe } = normalizeFilename(filename);\n return `tenants/${tenantId}/${category}/${safe}`;\n}\n\nexport interface R2PutOptions {\n contentType: string;\n cacheControl?: string;\n metadata?: Record<string, string>;\n}\n\nexport interface R2Object {\n key: string;\n size: number;\n etag: string;\n uploaded: Date;\n contentType?: string;\n metadata?: Record<string, string>;\n}\n\n/**\n * R2 client interface — environment-specific binding 이 본 interface 구현.\n * tenant-admin 가 server action 안에서 inject (DI).\n */\nexport interface R2Client {\n put(key: string, body: ArrayBuffer | Uint8Array, options: R2PutOptions): Promise<R2Object>;\n get(key: string): Promise<ArrayBuffer | null>;\n delete(key: string): Promise<void>;\n /** presigned URL — 외부 캐싱/CDN 용. Phase 1 = direct R2 public bucket */\n getPublicUrl(key: string): string;\n}\n\n/**\n * Tenant-aware R2 helper — key prefix 강제 + Korean filename normalize 자동.\n * tenant 외부에서 본 wrapper 우회하면 ESLint 의 no-raw-db-client 와 비슷한 rule\n * 추가 검토 (Phase 1b).\n */\nexport class TenantR2 {\n constructor(\n private readonly client: R2Client,\n private readonly tenantId: string,\n ) {}\n\n async uploadMedia(\n filename: string,\n body: ArrayBuffer | Uint8Array,\n contentType: string,\n ): Promise<{ key: string; publicUrl: string; object: R2Object }> {\n const key = r2KeyForTenant(this.tenantId, \"media\", filename);\n const object = await this.client.put(key, body, { contentType });\n return { key, publicUrl: this.client.getPublicUrl(key), object };\n }\n\n async deleteByKey(key: string): Promise<void> {\n if (!key.startsWith(`tenants/${this.tenantId}/`)) {\n throw new Error(\n `tenant 격리 위반: ${key} 는 tenants/${this.tenantId}/ prefix 와 일치하지 않음`,\n );\n }\n await this.client.delete(key);\n }\n}\n","// ADR-0034 §7.1.1 test #4 — AWS S3 SDK based R2Client 실 구현.\n//\n// Cloudflare R2 는 S3-compatible API 노출. @aws-sdk/client-s3 wrapper.\n// caller 가 endpoint + access key (Doppler) 주입.\n\nimport type { R2Client, R2Object, R2PutOptions } from \"./r2-client\";\n\nexport interface S3ClientConfig {\n endpoint: string; // e.g. https://<account>.r2.cloudflarestorage.com\n region?: string; // R2 = \"auto\"\n bucket: string;\n accessKeyId: string;\n secretAccessKey: string;\n publicBaseUrl: string; // CDN 또는 R2 public URL prefix\n}\n\n/**\n * AWS SDK 의존을 minimal — type-only declaration.\n * 실 binding 은 tenant-admin 의 server action 또는 tenant-api 가 SDK 주입.\n * 본 module 은 R2Client interface 의 S3-style 구현 스켈레톤.\n */\ninterface MinimalS3Sdk {\n send(command: unknown): Promise<{ ETag?: string; ContentLength?: number; Body?: AsyncIterable<Uint8Array> }>;\n}\n\ninterface S3PutObjectCommandInput {\n Bucket: string;\n Key: string;\n Body: Uint8Array;\n ContentType: string;\n CacheControl?: string;\n Metadata?: Record<string, string>;\n}\n\ninterface S3GetObjectCommandInput {\n Bucket: string;\n Key: string;\n}\n\ninterface S3DeleteObjectCommandInput {\n Bucket: string;\n Key: string;\n}\n\nexport interface S3CommandFactories {\n PutObjectCommand: new (input: S3PutObjectCommandInput) => unknown;\n GetObjectCommand: new (input: S3GetObjectCommandInput) => unknown;\n DeleteObjectCommand: new (input: S3DeleteObjectCommandInput) => unknown;\n}\n\n/**\n * R2Client 구현 (S3 SDK based). caller 가 SDK + commands 주입.\n *\n * 사용 예시 (tenant-admin):\n * import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand }\n * from \"@aws-sdk/client-s3\";\n * const r2 = new S3R2Client(\n * new S3Client({ endpoint, region: \"auto\", credentials: {...} }),\n * { PutObjectCommand, GetObjectCommand, DeleteObjectCommand },\n * config,\n * );\n */\nexport class S3R2Client implements R2Client {\n constructor(\n private readonly sdk: MinimalS3Sdk,\n private readonly commands: S3CommandFactories,\n private readonly config: S3ClientConfig,\n ) {}\n\n async put(\n key: string,\n body: ArrayBuffer | Uint8Array,\n options: R2PutOptions,\n ): Promise<R2Object> {\n const buf = body instanceof Uint8Array ? body : new Uint8Array(body);\n const cmd = new this.commands.PutObjectCommand({\n Bucket: this.config.bucket,\n Key: key,\n Body: buf,\n ContentType: options.contentType,\n CacheControl: options.cacheControl,\n Metadata: options.metadata,\n });\n const result = await this.sdk.send(cmd);\n return {\n key,\n size: buf.byteLength,\n etag: result.ETag ?? \"\",\n uploaded: new Date(),\n contentType: options.contentType,\n metadata: options.metadata,\n };\n }\n\n async get(key: string): Promise<ArrayBuffer | null> {\n const cmd = new this.commands.GetObjectCommand({\n Bucket: this.config.bucket,\n Key: key,\n });\n try {\n const result = await this.sdk.send(cmd);\n if (!result.Body) return null;\n const chunks: Uint8Array[] = [];\n for await (const chunk of result.Body) {\n chunks.push(chunk);\n }\n const total = chunks.reduce((a, c) => a + c.byteLength, 0);\n const merged = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n return merged.buffer;\n } catch (_err) {\n return null;\n }\n }\n\n async delete(key: string): Promise<void> {\n const cmd = new this.commands.DeleteObjectCommand({\n Bucket: this.config.bucket,\n Key: key,\n });\n await this.sdk.send(cmd);\n }\n\n getPublicUrl(key: string): string {\n const base = this.config.publicBaseUrl.replace(/\\/$/, \"\");\n return `${base}/${key}`;\n }\n}\n","import type { ArchiveReceipt } from \"./archive-types\";\n\n/** Cached proof is a backup observation, never permission to create/delete media. */\nexport const ARCHIVE_IDENTITY_MAX_AGE_MS = 45 * 60_000;\nexport const ARCHIVE_BYTES_MAX_AGE_MS = 26 * 60 * 60_000;\nexport const ARCHIVE_FULL_SCRUB_INTERVAL_MS = 24 * 60 * 60_000;\nexport const ARCHIVE_EVIDENCE_BATCH_LIMIT = 10_000;\nexport interface ArchiveObjectIdentity { etag: string; bytes: number }\nexport interface ArchiveSourceLocator { tenantId: string; sourceBucket: string; sourceKey: string }\nexport interface ArchiveIdentityProof {\n method: \"receipt_identity\";\n byteVerifiedAt: string;\n identityCheckedAt: string;\n ciphertext: ArchiveObjectIdentity;\n metadata: ArchiveObjectIdentity;\n}\nexport interface ArchiveSourceEvidence {\n tenantId: string;\n sourceDigest: string;\n reason: \"verified\" | \"evidence_missing\" | \"evidence_stale\";\n receipt: ArchiveReceipt | null;\n proof: ArchiveIdentityProof | null;\n /** Last completed source reconciliation attempt; scheduling only, never verification proof. */\n lastAttemptAt?: string;\n}\nexport function archiveProofFresh(proof: ArchiveIdentityProof, now: number): boolean {\n const bytesAge = now - Date.parse(proof.byteVerifiedAt);\n const identityAge = now - Date.parse(proof.identityCheckedAt);\n return Number.isFinite(bytesAge) && bytesAge >= 0 && bytesAge <= ARCHIVE_BYTES_MAX_AGE_MS\n && Number.isFinite(identityAge) && identityAge >= 0 && identityAge <= ARCHIVE_IDENTITY_MAX_AGE_MS\n && Date.parse(proof.identityCheckedAt) >= Date.parse(proof.byteVerifiedAt);\n}\nexport function parseArchiveIdentityProof(value: unknown): ArchiveIdentityProof {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) throw new Error(\"Invalid archive proof\");\n const proof = value as Record<string, unknown>;\n function time(value: unknown): string {\n if (typeof value !== \"string\" || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value) throw new Error(\"Invalid archive proof date\");\n return value;\n }\n function identity(value: unknown): ArchiveObjectIdentity {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) throw new Error(\"Invalid archive identity\");\n const object = value as Record<string, unknown>;\n if (typeof object.etag !== \"string\" || !object.etag || object.etag.length > 256 || /[\\x00-\\x20\\x7f]/.test(object.etag)\n || typeof object.bytes !== \"number\" || !Number.isSafeInteger(object.bytes) || object.bytes <= 0) throw new Error(\"Invalid archive identity\");\n return { etag: object.etag, bytes: object.bytes };\n }\n if (proof.method !== \"receipt_identity\") throw new Error(\"Invalid archive proof method\");\n return { method: proof.method, byteVerifiedAt: time(proof.byteVerifiedAt), identityCheckedAt: time(proof.identityCheckedAt),\n ciphertext: identity(proof.ciphertext), metadata: identity(proof.metadata) };\n}\n","import { ARCHIVE_EVIDENCE_BATCH_LIMIT, parseArchiveIdentityProof, type ArchiveSourceLocator, type ArchiveSourceEvidence } from \"./archive-evidence\";\nimport type { ArchiveReceipt } from \"./archive-types\";\nexport type { ArchiveReceipt } from \"./archive-types\";\nexport * from \"./archive-evidence\";\n/** Online archive stores only GPG ciphertext; applications never receive backup credentials. */\nexport interface MediaArchiveConfig {\n baseUrl: string;\n token: string;\n fetchImpl?: typeof fetch;\n sleep?: (ms: number) => Promise<void>;\n}\n\nexport interface PreserveMediaInput {\n tenantId: string;\n sourceKey: string;\n sourceBucket: string;\n contentType: string;\n bytes: ArrayBuffer;\n uploadId?: string;\n}\n\nexport interface MediaTombstoneInput {\n tenantId: string;\n sourceKey: string;\n sourceBucket: string;\n receipt: ArchiveReceipt;\n deletedAt: string;\n metadata: Record<string, unknown>;\n}\n\nexport interface MediaArchiveClient {\n preserve(input: PreserveMediaInput): Promise<ArchiveReceipt>;\n verify(receipt: ArchiveReceipt): Promise<ArchiveReceipt>;\n findSource(input: { tenantId: string; sourceBucket: string; sourceKey: string }): Promise<ArchiveReceipt | null>;\n tombstone(input: MediaTombstoneInput): Promise<void>;\n /** Read-only cached observation. Must never authorize an upload or deletion. */\n evidence?(sources: ArchiveSourceLocator[]): Promise<ArchiveSourceEvidence[]>;\n /** Separate scrub workload: verifies source/receipt identity and periodically bytes. */\n reconcileSource?(source: ArchiveSourceLocator): Promise<ArchiveSourceEvidence>;\n}\n\nexport const ARCHIVE_FAILURE_REASONS = [\"missing_receipt\", \"checksum_mismatch\", \"invalid_receipt\", \"auth\", \"throttled\", \"timeout\", \"transport\", \"busy\", \"unavailable\"] as const;\nexport type ArchiveFailureReason = typeof ARCHIVE_FAILURE_REASONS[number];\nexport class MediaPreservationError extends Error {\n constructor(message = \"파일 보존을 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.\",\n readonly reason: ArchiveFailureReason = \"invalid_receipt\", readonly status?: number) {\n super(message);\n this.name = \"MediaPreservationError\";\n }\n}\n\n/** Organization support attachments have their own owner namespace, never a fake tenant. */\nexport function archiveOwnerPrefix(scopeId: string): string {\n if (/^org-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(scopeId)) {\n return `organizations/${scopeId.slice(4)}/`;\n }\n if (!/^[A-Za-z0-9_-]+$/.test(scopeId) || scopeId.startsWith(\"org-\")) throw new MediaPreservationError();\n return `tenants/${scopeId}/`;\n}\n\nexport function assertTenantObjectKey(tenantId: string, key: string): void {\n if (!/^[A-Za-z0-9_-]+$/.test(tenantId) ||\n !(key.startsWith(archiveOwnerPrefix(tenantId)) ||\n (tenantId.startsWith(\"org-\") && key.startsWith(`support/${tenantId.slice(4)}/`)) ||\n (!tenantId.startsWith(\"org-\") && key.startsWith(`${tenantId}/medical/records/`))) ||\n key.split(\"/\").some((part) => !part || part === \".\" || part === \"..\") ||\n /[\\\\\\x00-\\x1f\\x7f]/.test(key)) {\n throw new MediaPreservationError(\"잘못된 파일 소유 경로입니다.\");\n }\n}\n\nexport async function mediaSha256(bytes: ArrayBuffer): Promise<string> {\n const digest = new Uint8Array(await crypto.subtle.digest(\"SHA-256\", bytes));\n return Array.from(digest, (byte) => byte.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nfunction metadataHeader(value: unknown): string {\n const bytes = new TextEncoder().encode(JSON.stringify(value));\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nfunction readReceipt(value: unknown, tenantId: string, sha256: string, sizeBytes: number): ArchiveReceipt {\n if (!/^[a-f0-9]{64}$/.test(sha256) || !Number.isSafeInteger(sizeBytes) || sizeBytes < 0 ||\n !value || typeof value !== \"object\") throw new MediaPreservationError();\n const item = value as Record<string, unknown>;\n if (item.version !== 1 || item.tenantId !== tenantId || item.sha256 !== sha256 ||\n item.sizeBytes !== sizeBytes ||\n typeof item.archiveKey !== \"string\" ||\n !new RegExp(`^${archiveOwnerPrefix(tenantId)}media-archive/${sha256}/[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/original\\\\.gpg$`).test(item.archiveKey) ||\n typeof item.cipherSha256 !== \"string\" || !/^[a-f0-9]{64}$/.test(item.cipherSha256) ||\n item.metadataKey !== item.archiveKey.replace(/original\\.gpg$/, \"metadata.gpg\") ||\n typeof item.metadataCipherSha256 !== \"string\" || !/^[a-f0-9]{64}$/.test(item.metadataCipherSha256) ||\n typeof item.verifiedAt !== \"string\" || !Number.isFinite(Date.parse(item.verifiedAt))) {\n throw new MediaPreservationError();\n }\n return { version: 1, tenantId, sha256, sizeBytes, archiveKey: item.archiveKey,\n cipherSha256: item.cipherSha256, metadataKey: item.metadataKey,\n metadataCipherSha256: item.metadataCipherSha256, verifiedAt: item.verifiedAt };\n}\n\nexport function createMediaArchiveClient(config: MediaArchiveConfig): MediaArchiveClient {\n const base = new URL(config.baseUrl);\n if (base.protocol !== \"https:\" || base.username || base.password || !config.token.trim()) {\n throw new MediaPreservationError(\"파일 보존 연결 설정을 확인해 주세요.\");\n }\n const fetchImpl = config.fetchImpl ?? fetch;\n const sleep = config.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));\n async function request(path: string, init: RequestInit, allowMissing = false, readOnly = false,\n timeoutMs = init.method === \"GET\" || readOnly ? 10_000 : 60_000, totalBudgetMs?: number): Promise<unknown> {\n const started = Date.now();\n for (let attempt = 1; ; attempt++) {\n const remaining = totalBudgetMs === undefined ? timeoutMs : totalBudgetMs - (Date.now() - started);\n if (remaining <= 0) throw new MediaPreservationError(undefined, \"timeout\");\n let retryAfterMs = 0;\n try {\n const headers = new Headers(init.headers);\n headers.set(\"Authorization\", `Bearer ${config.token}`);\n const response = await fetchImpl(new URL(path, base), {\n // Workers reject redirect:\"error\"; manual keeps credentials on this origin.\n ...init, headers, redirect: \"manual\", signal: AbortSignal.timeout(Math.min(timeoutMs, remaining)),\n });\n if (response.status === 404 && allowMissing) { await response.body?.cancel(); return null; }\n if (!response.ok) {\n const rawReason = response.headers.get(\"x-archive-failure\");\n const reason = ARCHIVE_FAILURE_REASONS.find((value) => value === rawReason)\n ?? ([401, 403].includes(response.status) ? \"auth\" : response.status === 429 ? \"throttled\"\n : [408, 500, 502, 503, 504].includes(response.status) ? \"unavailable\" : \"invalid_receipt\");\n const retryAfter = response.headers.get(\"retry-after\");\n retryAfterMs = retryAfter && /^\\d+$/.test(retryAfter) ? Number(retryAfter) * 1000\n : retryAfter && Number.isFinite(Date.parse(retryAfter)) ? Math.max(0, Date.parse(retryAfter) - Date.now()) : 0;\n await response.body?.cancel();\n throw new MediaPreservationError(undefined, reason, response.status);\n }\n return await response.json();\n } catch (error) {\n // Gateway failures must not leak a URL, token, or source metadata to client logs.\n const safe = error instanceof MediaPreservationError ? error : new MediaPreservationError(undefined,\n error instanceof Error && [\"TimeoutError\", \"AbortError\"].includes(error.name) ? \"timeout\"\n : error instanceof SyntaxError ? \"invalid_receipt\" : \"transport\");\n const delay = Math.max(retryAfterMs, 250 * 2 ** (attempt - 1) + Math.random() * 250);\n const budgetExceeded = totalBudgetMs === undefined\n ? Date.now() - started + delay + timeoutMs > 3 * timeoutMs + 5_000\n : Date.now() - started + delay >= totalBudgetMs;\n if ((!readOnly && init.method !== \"GET\") || attempt >= 3 || ![\"transport\", \"timeout\", \"throttled\", \"busy\", \"unavailable\"].includes(safe.reason)\n || budgetExceeded) throw safe;\n await sleep(delay);\n }\n }\n }\n function validateSource(input: ArchiveSourceLocator): void {\n assertTenantObjectKey(input.tenantId, input.sourceKey);\n if (!/^[a-z0-9][a-z0-9.-]{1,62}$/.test(input.sourceBucket)) throw new MediaPreservationError();\n }\n async function sourceDigest(input: ArchiveSourceLocator): Promise<string> {\n validateSource(input);\n return mediaSha256(new TextEncoder().encode(`${input.sourceBucket}\\n${input.sourceKey}`).buffer);\n }\n async function evidenceItem(value: unknown, input: ArchiveSourceLocator): Promise<ArchiveSourceEvidence> {\n try {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) throw new Error();\n const item = value as Record<string, unknown>;\n const digest = await sourceDigest(input);\n if (item.tenantId !== input.tenantId || item.sourceDigest !== digest) throw new Error();\n let lastAttemptAt: string | undefined;\n if (item.lastAttemptAt !== undefined) {\n if (typeof item.lastAttemptAt !== \"string\" || !Number.isFinite(Date.parse(item.lastAttemptAt))\n || new Date(item.lastAttemptAt).toISOString() !== item.lastAttemptAt) throw new Error();\n lastAttemptAt = item.lastAttemptAt;\n }\n const attemptHint = lastAttemptAt === undefined ? {} : { lastAttemptAt };\n if (item.reason === \"evidence_missing\" || item.reason === \"evidence_stale\") {\n if (item.receipt !== null || item.proof !== null) throw new Error();\n return { tenantId: input.tenantId, sourceDigest: digest, reason: item.reason, receipt: null, proof: null, ...attemptHint };\n }\n if (item.reason !== \"verified\" || !item.receipt || typeof item.receipt !== \"object\") throw new Error();\n const raw = item.receipt as Record<string, unknown>;\n if (typeof raw.sha256 !== \"string\" || typeof raw.sizeBytes !== \"number\") throw new Error();\n return { tenantId: input.tenantId, sourceDigest: digest, reason: \"verified\",\n receipt: readReceipt(raw, input.tenantId, raw.sha256, raw.sizeBytes), proof: parseArchiveIdentityProof(item.proof), ...attemptHint };\n } catch { throw new MediaPreservationError(); }\n }\n return {\n async evidence(sources) {\n if (sources.length > ARCHIVE_EVIDENCE_BATCH_LIMIT) throw new MediaPreservationError();\n for (const source of sources) validateSource(source);\n const value = await request(\"/v2/source-evidence\", { method: \"POST\", headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ version: 2, sources }) }, false, true);\n if (!value || typeof value !== \"object\" || !(\"version\" in value) || value.version !== 2\n || !(\"items\" in value) || !Array.isArray(value.items) || value.items.length !== sources.length) throw new MediaPreservationError();\n const parsed: ArchiveSourceEvidence[] = [];\n // Bound WebCrypto jobs and temporary receipt objects as inventory grows.\n for (let start = 0; start < sources.length; start += 64) {\n parsed.push(...await Promise.all(value.items.slice(start, start + 64)\n .map((item, offset) => evidenceItem(item, sources[start + offset]!))));\n }\n return parsed;\n },\n async reconcileSource(source) {\n const digest = await sourceDigest(source);\n // Cold/periodic scrub may stream a complete object. Let the gateway's 55s\n // operation deadline settle. Retries share one 60s budget so the scrub can\n // still publish its result before shutdown; snapshot observation keeps 10s.\n const value = await request(`/v2/sources/${source.tenantId}/${digest}`, { method: \"GET\" }, true, false, 60_000, 60_000);\n if (value === null) throw new MediaPreservationError(undefined, \"missing_receipt\");\n return evidenceItem(value, source);\n },\n async preserve(input) {\n assertTenantObjectKey(input.tenantId, input.sourceKey);\n const sha256 = await mediaSha256(input.bytes);\n const value = await request(`/v1/objects/${input.tenantId}/${sha256}`, {\n method: \"PUT\",\n headers: { \"Content-Type\": input.contentType, \"X-Archive-Metadata\": metadataHeader({\n sourceKey: input.sourceKey, sourceBucket: input.sourceBucket, contentType: input.contentType, uploadId: input.uploadId,\n }) },\n body: input.bytes,\n });\n return readReceipt(value, input.tenantId, sha256, input.bytes.byteLength);\n },\n async findSource(input) {\n assertTenantObjectKey(input.tenantId, input.sourceKey);\n const hash = await mediaSha256(new TextEncoder().encode(`${input.sourceBucket}\\n${input.sourceKey}`).buffer);\n const value = await request(`/v1/sources/${input.tenantId}/${hash}`, { method: \"GET\" }, true);\n if (value === null) return null;\n if (!value || typeof value !== \"object\" || !(\"sha256\" in value) || typeof value.sha256 !== \"string\" ||\n !/^[a-f0-9]{64}$/.test(value.sha256) || !(\"sizeBytes\" in value) || typeof value.sizeBytes !== \"number\") {\n throw new MediaPreservationError();\n }\n return readReceipt(value, input.tenantId, value.sha256, value.sizeBytes);\n },\n async verify(receipt) {\n readReceipt(receipt, receipt.tenantId, receipt.sha256, receipt.sizeBytes);\n const value = await request(`/v1/objects/${receipt.tenantId}/${receipt.sha256}`, { method: \"GET\" });\n const verified = readReceipt(value, receipt.tenantId, receipt.sha256, receipt.sizeBytes);\n if (verified.cipherSha256 !== receipt.cipherSha256 || verified.metadataKey !== receipt.metadataKey ||\n verified.metadataCipherSha256 !== receipt.metadataCipherSha256) throw new MediaPreservationError();\n return verified;\n },\n async tombstone(input) {\n assertTenantObjectKey(input.tenantId, input.sourceKey);\n if (input.receipt.tenantId !== input.tenantId) throw new MediaPreservationError();\n const value = await request(\"/v1/tombstones\", {\n method: \"POST\", headers: { \"Content-Type\": \"application/json\" }, body: JSON.stringify(input),\n });\n if (!value || typeof value !== \"object\" || !(\"recorded\" in value) || value.recorded !== true ||\n !(\"tombstoneKey\" in value) || typeof value.tombstoneKey !== \"string\" ||\n !value.tombstoneKey.startsWith(archiveOwnerPrefix(input.tenantId))) throw new MediaPreservationError();\n },\n };\n}\n\nexport interface PreservationStorage {\n get(key: string): Promise<{ bytes: ArrayBuffer; contentType: string } | null>;\n /** Conditional create only. Existing keys must never be overwritten. */\n putIfAbsent(key: string, bytes: ArrayBuffer, contentType: string): Promise<void>;\n}\n\nexport interface FinalizePreservedUploadInput {\n tenantId: string;\n siteId: string;\n stagingKey: string;\n expectedContentType: string;\n expectedSize: number;\n sourceBucket: string;\n storage: PreservationStorage;\n archive: MediaArchiveClient;\n}\n\n/** Staging upload ID is the media primary key, so retries cannot insert duplicate rows. */\nexport function mediaUploadId(stagingKey: string): string {\n const match = /\\/staging\\/([0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})[.-]/.exec(stagingKey);\n if (!match?.[1]) throw new MediaPreservationError(\"잘못된 업로드 경로입니다.\");\n return match[1];\n}\n\nexport async function finalizePreservedUpload(input: FinalizePreservedUploadInput): Promise<{\n id: string; key: string; receipt: ArchiveReceipt;\n}> {\n assertTenantObjectKey(input.tenantId, input.stagingKey);\n const id = mediaUploadId(input.stagingKey);\n const original = await input.storage.get(input.stagingKey);\n if (!original || original.bytes.byteLength !== input.expectedSize ||\n original.contentType.split(\";\", 1)[0]?.trim().toLowerCase() !== input.expectedContentType) {\n throw new MediaPreservationError(\"업로드된 파일의 형식과 크기를 확인해 주세요.\");\n }\n const sha256 = await mediaSha256(original.bytes);\n const filename = input.stagingKey.slice(input.stagingKey.lastIndexOf(\"/\") + 1);\n const key = `tenants/${input.tenantId}/sites/${input.siteId}/media/originals/${sha256}/${filename}`;\n await input.storage.putIfAbsent(key, original.bytes, input.expectedContentType);\n const final = await input.storage.get(key);\n if (!final || await mediaSha256(final.bytes) !== sha256) throw new MediaPreservationError();\n const receipt = await input.archive.preserve({ tenantId: input.tenantId, sourceBucket: input.sourceBucket,\n sourceKey: key, contentType: input.expectedContentType, bytes: final.bytes, uploadId: id });\n return { id, key, receipt };\n}\n\n/** Snapshot bytes once; later source mutations cannot change the encrypted copy. */\nexport async function preserveStoredObject(input: {\n tenantId: string; sourceBucket: string; sourceKey: string;\n storage: Pick<PreservationStorage, \"get\">; archive: MediaArchiveClient;\n}): Promise<ArchiveReceipt> {\n assertTenantObjectKey(input.tenantId, input.sourceKey);\n const original = await input.storage.get(input.sourceKey);\n if (!original) {\n const existing = await input.archive.findSource(input);\n if (!existing) throw new MediaPreservationError(\"보존할 원본 파일을 찾을 수 없습니다.\");\n return input.archive.verify(existing);\n }\n return input.archive.preserve({ tenantId: input.tenantId, sourceBucket: input.sourceBucket,\n sourceKey: input.sourceKey, ...original });\n}\n","import {\n assertTenantObjectKey,\n mediaSha256,\n MediaPreservationError,\n type ArchiveReceipt,\n type MediaArchiveClient,\n type PreservationStorage,\n} from \"./archive\";\n\n/** Caller authorizes the staging locator; only the new tenant-owned key can be archived. */\nexport async function preserveStoredUpload(input: {\n tenantId: string;\n sourceKey: string;\n sourceBucket: string;\n expectedSize: number;\n allowedContentTypes: readonly string[];\n finalKey: (sha256: string) => string;\n storage: PreservationStorage;\n archive: MediaArchiveClient;\n}): Promise<{ key: string; receipt: ArchiveReceipt }> {\n const original = await input.storage.get(input.sourceKey);\n const contentType = original?.contentType.split(\";\", 1)[0]?.trim().toLowerCase();\n if (!original || original.bytes.byteLength !== input.expectedSize || !contentType ||\n !input.allowedContentTypes.includes(contentType)) {\n throw new MediaPreservationError(\"업로드된 파일의 형식과 크기를 확인해 주세요.\");\n }\n const sha256 = await mediaSha256(original.bytes);\n const key = input.finalKey(sha256);\n assertTenantObjectKey(input.tenantId, key);\n if (key === input.sourceKey) throw new MediaPreservationError(\"보존 파일은 별도 경로가 필요합니다.\");\n await input.storage.putIfAbsent(key, original.bytes, contentType);\n const stored = await input.storage.get(key);\n if (!stored || await mediaSha256(stored.bytes) !== sha256 ||\n stored.contentType.split(\";\", 1)[0]?.trim().toLowerCase() !== contentType) {\n throw new MediaPreservationError();\n }\n const receipt = await input.archive.preserve({\n tenantId: input.tenantId,\n sourceKey: key,\n sourceBucket: input.sourceBucket,\n contentType,\n bytes: stored.bytes,\n });\n return { key, receipt };\n}\n"],"mappings":"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","names":["value"]}
@@ -0,0 +1,87 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import * as react from 'react';
3
+ import { ImgHTMLAttributes, CSSProperties, ReactNode } from 'react';
4
+
5
+ /**
6
+ * RootTale 공유 CF Images 렌더 컴포넌트 — next/image 대체. (`@roottale/cms-media/react`)
7
+ *
8
+ * 왜: next/image는 Vercel 이미지 최적화 함수(/_next/image, Fluid Compute)를 태워 비용·지연 발생.
9
+ * 콘텐츠 이미지는 Cloudflare Images(imagedelivery.net)에 두고 CF variant(sm/md/lg, 자동 webp/avif)
10
+ * srcset으로 브라우저가 직접 받게 한다 → Vercel 컴퓨트 0. (ADR-0034 / docs/image-standard.md)
11
+ *
12
+ * - CF imagedelivery URL: srcset(sm/md/lg) + 기본 src=md. 비-CF URL(로컬 SVG·외부)은 변형 없이 그대로.
13
+ * - placeholder(로더): 'blur' | 'skeleton' | 'image' | 'none'. 기본 'blur'.
14
+ * · 'blur'(CF): skeleton shimmer 를 즉시 깔고(=마운트 순간 로딩 인지) 그 위로 CF thumbnail
15
+ * 흐림을 썸네일이 받아지는 순간 페이드인 → 원본 받아지면 둘 다 페이드아웃. blur 썸네일도
16
+ * 네트워크라 받기 전 빈 화면이 생기던 갭을 skeleton 이 메운다. (CF 아니면 skeleton 단독)
17
+ * · 'skeleton': shimmer 단독.
18
+ * · 'image': loaderImage(브랜드 스피너/로고 등)를 가운데 표시.
19
+ * · loader: 임의 ReactNode 를 로더로 직접 주입(최우선). 무엇이든 커스텀 가능.
20
+ * - 원본 onLoad 시 opacity 0→1 페이드인.
21
+ *
22
+ * next/image의 흔한 사용(fill, sizes, priority, width/height, style, loading, onError)과 호환.
23
+ */
24
+ type CfImagePlaceholder = "blur" | "skeleton" | "image" | "none";
25
+ interface CfImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "placeholder"> {
26
+ src: string;
27
+ alt: string;
28
+ /** next/image의 fill 처럼 부모(position:relative)를 채운다. */
29
+ fill?: boolean;
30
+ /** LCP 요소면 true → eager + fetchPriority high + 페이드 생략(즉시). */
31
+ priority?: boolean;
32
+ /** fill일 때 object-fit (기본 cover). */
33
+ objectFit?: CSSProperties["objectFit"];
34
+ /** 로딩 중 표시(로더). 기본 'blur'(CF가 아니면 skeleton로 폴백). 'image'면 loaderImage 사용. */
35
+ placeholder?: CfImagePlaceholder;
36
+ /** placeholder='image'일 때 로딩 중 가운데 표시할 커스텀 이미지 URL. */
37
+ loaderImage?: string;
38
+ /** 임의 커스텀 로더(스피너 컴포넌트 등). 지정 시 placeholder/loaderImage보다 우선. */
39
+ loader?: ReactNode;
40
+ /** 로더 배경색(image/custom/skeleton 컨테이너). 기본 연한 중립. */
41
+ loaderBackground?: string;
42
+ /** 래퍼 클래스/스타일 (fill 레이아웃 제어용). */
43
+ wrapperClassName?: string;
44
+ wrapperStyle?: CSSProperties;
45
+ }
46
+ declare function CfImage({ src, alt, fill, priority, objectFit, placeholder, loaderImage, loader, loaderBackground, sizes, width, height, loading, style, className, wrapperClassName, wrapperStyle, onLoad, onError, ...rest }: CfImageProps): react_jsx_runtime.JSX.Element;
47
+
48
+ /** Browser/server URL helpers. No React, upload clients, environment access or network requests. */
49
+ interface StaticImageSource {
50
+ src: string;
51
+ width?: number;
52
+ height?: number;
53
+ }
54
+ type ImageSource = string | StaticImageSource;
55
+ interface ImageAttributes {
56
+ src: string;
57
+ srcSet?: string;
58
+ sizes?: string;
59
+ }
60
+
61
+ interface RootTaleImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt"> {
62
+ src: ImageSource;
63
+ alt: string;
64
+ fill?: boolean;
65
+ priority?: boolean;
66
+ preload?: boolean;
67
+ /** A site adapter can resolve local/R2 assets without putting its registry in this package. */
68
+ imageAttributes?: ImageAttributes;
69
+ fallbackSrc?: string;
70
+ /** Scope direct CF URL transformations to the site's account. */
71
+ accountHash?: string;
72
+ }
73
+ /** RootTale's native image boundary. No wrapper, injected CSS, Next.js or network/upload clients. */
74
+ declare const RootTaleImage: react.ForwardRefExoticComponent<RootTaleImageProps & react.RefAttributes<HTMLImageElement>>;
75
+
76
+ type ImageVariant = "thumbnail" | "sm" | "md" | "lg";
77
+ /** imagedelivery delivery URL 여부. */
78
+ declare function isCfImageUrl(url: string): boolean;
79
+ /**
80
+ * delivery URL 의 끝 variant 세그먼트를 교체. 세그먼트가 없으면 덧붙인다.
81
+ * CF URL 이 아니면 원본 그대로 반환(외부/로컬 URL 안전 통과).
82
+ */
83
+ declare function cfImageVariant(url: string, variant: ImageVariant | "public"): string;
84
+ /** sm/md/lg srcset 문자열(CF URL 일 때만, 아니면 undefined). 폭은 VARIANT_SIZES 기준. */
85
+ declare function cfImageSrcSet(url: string, variants?: readonly ImageVariant[]): string | undefined;
86
+
87
+ export { CfImage, type CfImagePlaceholder, type CfImageProps, type ImageVariant, RootTaleImage, type RootTaleImageProps, cfImageSrcSet, cfImageVariant, CfImage as default, isCfImageUrl };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-media",
3
- "version": "0.71.0",
3
+ "version": "0.72.0",
4
4
  "type": "module",
5
5
  "description": "RootTale 미디어 — R2 원본 보관 + CF Images variants + 한글 파일명 정규화 + CfImage 렌더 컴포넌트(@roottale/cms-media/react). ADR-0034 §3 / docs/image-standard.md.",
6
6
  "main": "./dist/index.js",