@revealui/core 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/client/admin/components/AdminDashboard.d.ts.map +1 -1
  2. package/dist/client/admin/components/AdminDashboard.js +103 -6
  3. package/dist/client/admin/components/DocumentForm.d.ts.map +1 -1
  4. package/dist/client/admin/components/DocumentForm.js +5 -1
  5. package/dist/client/admin/page.d.ts.map +1 -1
  6. package/dist/client/admin/page.js +1 -1
  7. package/dist/client/admin/utils/apiClient.d.ts.map +1 -1
  8. package/dist/client/admin/utils/apiClient.js +27 -1
  9. package/dist/client/admin/utils/csrf.d.ts +4 -3
  10. package/dist/client/admin/utils/csrf.d.ts.map +1 -1
  11. package/dist/client/admin/utils/csrf.js +4 -3
  12. package/dist/client/richtext/components/ImageUploadButton.d.ts.map +1 -1
  13. package/dist/client/richtext/components/ImageUploadButton.js +17 -4
  14. package/dist/client/richtext/components/upload.d.ts +7 -0
  15. package/dist/client/richtext/components/upload.d.ts.map +1 -1
  16. package/dist/client/richtext/components/upload.js +10 -0
  17. package/dist/client/richtext/nodes/DecoratorBlockNode.d.ts +4 -0
  18. package/dist/client/richtext/nodes/DecoratorBlockNode.d.ts.map +1 -1
  19. package/dist/client/richtext/nodes/DecoratorBlockNode.js +12 -0
  20. package/dist/features.d.ts +2 -0
  21. package/dist/features.d.ts.map +1 -1
  22. package/dist/features.js +2 -0
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -1
  26. package/dist/license/mint-client.d.ts +6 -0
  27. package/dist/license/mint-client.d.ts.map +1 -1
  28. package/dist/license/mint-client.js +34 -16
  29. package/dist/license.d.ts +6 -0
  30. package/dist/license.d.ts.map +1 -1
  31. package/dist/license.js +15 -0
  32. package/dist/monitoring/alerts.d.ts +6 -1
  33. package/dist/monitoring/alerts.d.ts.map +1 -1
  34. package/dist/monitoring/alerts.js +6 -1
  35. package/dist/observability/alerts.d.ts +7 -2
  36. package/dist/observability/alerts.d.ts.map +1 -1
  37. package/dist/observability/alerts.js +7 -2
  38. package/dist/observability/capture.d.ts +63 -0
  39. package/dist/observability/capture.d.ts.map +1 -0
  40. package/dist/observability/capture.js +125 -0
  41. package/dist/observability/cron-failure-alert.d.ts +59 -0
  42. package/dist/observability/cron-failure-alert.d.ts.map +1 -0
  43. package/dist/observability/cron-failure-alert.js +109 -0
  44. package/dist/observability/index.d.ts +2 -0
  45. package/dist/observability/index.d.ts.map +1 -1
  46. package/dist/observability/index.js +2 -0
  47. package/dist/revforge-license-boot.d.ts +35 -0
  48. package/dist/revforge-license-boot.d.ts.map +1 -0
  49. package/dist/revforge-license-boot.js +115 -0
  50. package/dist/storage/_sigv4.d.ts +35 -1
  51. package/dist/storage/_sigv4.d.ts.map +1 -1
  52. package/dist/storage/_sigv4.js +53 -0
  53. package/dist/storage/index.d.ts +1 -1
  54. package/dist/storage/index.d.ts.map +1 -1
  55. package/dist/storage/index.js +2 -3
  56. package/dist/storage/mock.d.ts +5 -2
  57. package/dist/storage/mock.d.ts.map +1 -1
  58. package/dist/storage/mock.js +54 -1
  59. package/dist/storage/r2.d.ts +6 -4
  60. package/dist/storage/r2.d.ts.map +1 -1
  61. package/dist/storage/r2.js +107 -13
  62. package/dist/storage/types.d.ts +54 -4
  63. package/dist/storage/types.d.ts.map +1 -1
  64. package/dist/vite/index.d.ts +2 -0
  65. package/dist/vite/index.d.ts.map +1 -0
  66. package/dist/vite/index.js +6 -0
  67. package/dist/vite/withRevealUI.d.ts +62 -0
  68. package/dist/vite/withRevealUI.d.ts.map +1 -0
  69. package/dist/vite/withRevealUI.js +94 -0
  70. package/package.json +30 -3
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revforge-license-boot.d.ts","sourceRoot":"","sources":["../src/revforge-license-boot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AASH,MAAM,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAExD;;;GAGG;AACH,eAAO,MAAM,8BAA8B,wCAAwC,CAAC;AAEpF;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAa5D;AAED;;;;;;;;;GASG;AACH,wBAAsB,6BAA6B,CACjD,GAAG,GAAE,MAA8B,GAClC,OAAO,CAAC,IAAI,CAAC,CA6Ef"}
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Shared Forge / self-host license boot gate for apps/admin + apps/server.
3
+ *
4
+ * Extracted from the intentional admin/server dual after the 2026-08-02 fleet
5
+ * redundancy audit. Server previously owned the full check (customer id, kid
6
+ * mismatch, domain binding); admin had a shortened copy. One implementation
7
+ * so the two cannot drift (instrumentation.ts TODO resolved).
8
+ *
9
+ * Throws on failure. Callers that must not throw (Next.js instrumentation)
10
+ * catch and `process.exit(1)`.
11
+ */
12
+ import { detectDeploymentMode as detectDeploymentModeCore, } from './deployment-mode.js';
13
+ import { computeKeyId, hostMatchesLicensedDomains, validateLicenseKey } from './license.js';
14
+ import { logger } from './observability/logger.js';
15
+ /**
16
+ * GAP-436 (owner-ruled 2026-07-26): plain OSS/self-host opt-in when no key is
17
+ * present. Stamped RevForge kits always bake a key, so they never hit this.
18
+ */
19
+ export const ALLOW_UNLICENSED_SELF_HOST_ENV = 'REVEALUI_ALLOW_UNLICENSED_SELF_HOST';
20
+ /**
21
+ * Extracts the `kid` (key id) from a JWT protected header WITHOUT verifying
22
+ * the signature. Used only to sharpen error messages after cryptographic
23
+ * verification has already failed.
24
+ */
25
+ export function decodeJwtKid(jwt) {
26
+ const headerSegment = jwt.split('.')[0];
27
+ if (!headerSegment)
28
+ return undefined;
29
+ try {
30
+ const header = JSON.parse(Buffer.from(headerSegment, 'base64url').toString('utf8'));
31
+ if (header && typeof header === 'object' && 'kid' in header) {
32
+ const { kid } = header;
33
+ return typeof kid === 'string' ? kid : undefined;
34
+ }
35
+ }
36
+ catch {
37
+ // Malformed header — caller surfaces generic invalid-license error.
38
+ }
39
+ return undefined;
40
+ }
41
+ /**
42
+ * Forge-mode license enforcement at process boot.
43
+ *
44
+ * - Hosted mode: no-op.
45
+ * - Forge mode + no key + ALLOW_UNLICENSED_SELF_HOST: Free (OSS) tier log, return.
46
+ * - Forge mode otherwise: require key + public key, verify JWT, optional
47
+ * customerId + domain binding.
48
+ *
49
+ * Honors `SKIP_ENV_VALIDATION=true` for Docker build / tests.
50
+ */
51
+ export async function validateForgeLicenseAtStartup(env = process.env) {
52
+ if (env.SKIP_ENV_VALIDATION === 'true') {
53
+ return;
54
+ }
55
+ const mode = detectDeploymentModeCore(env);
56
+ if (mode !== 'forge') {
57
+ return;
58
+ }
59
+ if (!env.REVEALUI_LICENSE_KEY) {
60
+ if (env[ALLOW_UNLICENSED_SELF_HOST_ENV] === 'true') {
61
+ logger.info('no license key — running Free (OSS) tier');
62
+ return;
63
+ }
64
+ throw new Error('LICENSE VALIDATION FAILED: REVEALUI_LICENSE_KEY is required for RevForge deployments. ' +
65
+ 'Run bin/revvault-bootstrap.sh to materialize docker/.env from revvault, ' +
66
+ 'or contact the operator who stamped this kit. A plain self-host deployment that ' +
67
+ `intends to run without a license should set ${ALLOW_UNLICENSED_SELF_HOST_ENV}=true to ` +
68
+ 'boot at Free (OSS) tier instead.');
69
+ }
70
+ if (!env.REVEALUI_LICENSE_PUBLIC_KEY) {
71
+ throw new Error('LICENSE VALIDATION FAILED: REVEALUI_LICENSE_PUBLIC_KEY is required for RevForge deployments. ' +
72
+ 'Stamped kits embed this value in docker/.env.example; check that it survived the bootstrap step.');
73
+ }
74
+ // Restore real newlines if the public key landed as a single-line PEM
75
+ // (stamp.sh .env encoding). Split/join, no authored regex — mirrors
76
+ // @revealui/core/license normalizePem. GAP-259 P0-4.
77
+ const publicKey = env.REVEALUI_LICENSE_PUBLIC_KEY.split('\\n').join('\n');
78
+ const expectedCustomerId = env.REVEALUI_LICENSED_CUSTOMER_ID || undefined;
79
+ const payload = await validateLicenseKey(env.REVEALUI_LICENSE_KEY, publicKey, expectedCustomerId);
80
+ if (!payload) {
81
+ const tokenKid = decodeJwtKid(env.REVEALUI_LICENSE_KEY);
82
+ if (tokenKid !== undefined) {
83
+ const expectedKid = await computeKeyId(publicKey);
84
+ if (tokenKid !== expectedKid) {
85
+ throw new Error('LICENSE VALIDATION FAILED: REVEALUI_LICENSE_PUBLIC_KEY does not match the key that ' +
86
+ `signed REVEALUI_LICENSE_KEY (license key id "${tokenKid}", configured public key id ` +
87
+ `"${expectedKid}"). The stamped kit baked the wrong public key. Re-issue the license ` +
88
+ 'with the matching keypair, or bake the public key that pairs with the signing key, ' +
89
+ 'then re-run bin/revvault-bootstrap.sh. Contact the operator who stamped this kit.');
90
+ }
91
+ }
92
+ throw new Error('LICENSE VALIDATION FAILED: REVEALUI_LICENSE_KEY is invalid, expired beyond grace, ' +
93
+ 'signed with a key that does not match REVEALUI_LICENSE_PUBLIC_KEY, or its ' +
94
+ 'customerId does not match REVEALUI_LICENSED_CUSTOMER_ID (if set). ' +
95
+ 'Contact the operator who stamped this kit to re-issue the license.');
96
+ }
97
+ if (payload.domains && payload.domains.length > 0) {
98
+ const publicUrl = (env.REVEALUI_PUBLIC_SERVER_URL ?? env.NEXT_PUBLIC_SERVER_URL ?? '').trim();
99
+ if (publicUrl) {
100
+ let host = '';
101
+ try {
102
+ host = new URL(publicUrl).hostname;
103
+ }
104
+ catch {
105
+ host = '';
106
+ }
107
+ if (!hostMatchesLicensedDomains(host, payload.domains)) {
108
+ throw new Error('LICENSE VALIDATION FAILED: this license is restricted to ' +
109
+ `[${payload.domains.join(', ')}], but REVEALUI_PUBLIC_SERVER_URL host ` +
110
+ `"${host || '(unparseable)'}" is not among them. Set REVEALUI_PUBLIC_SERVER_URL ` +
111
+ 'to a licensed domain, or contact the operator who stamped this kit.');
112
+ }
113
+ }
114
+ }
115
+ }
@@ -45,9 +45,11 @@ export interface SigV4Result {
45
45
  * key → signature. Verified against AWS's official `get-vanilla` test vector.
46
46
  */
47
47
  export declare function computeSigV4(input: CanonicalInput): SigV4Result;
48
+ /** Payload hash token for presigned URLs (body hash unknown at sign time). */
49
+ export declare const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
48
50
  /** Inputs for an R2/S3 request signature. */
49
51
  export interface SignS3Input {
50
- method: 'GET' | 'PUT' | 'DELETE';
52
+ method: 'GET' | 'PUT' | 'DELETE' | 'HEAD';
51
53
  accountId: string;
52
54
  bucket: string;
53
55
  /** Object key; omit for bucket-level operations (e.g. ListObjectsV2). */
@@ -73,4 +75,36 @@ export interface SignedS3Request {
73
75
  * from the URL (same value), and `Host` is a forbidden header to set manually.
74
76
  */
75
77
  export declare function signS3Request(input: SignS3Input): SignedS3Request;
78
+ /** Inputs for a query-string presigned S3/R2 URL (no Authorization header). */
79
+ export interface SignS3PresignedInput {
80
+ method: 'GET' | 'PUT' | 'DELETE' | 'HEAD';
81
+ accountId: string;
82
+ bucket: string;
83
+ key: string;
84
+ region: string;
85
+ accessKeyId: string;
86
+ secretAccessKey: string;
87
+ /** Lifetime of the URL in seconds. */
88
+ expiresInSeconds: number;
89
+ /**
90
+ * Headers the client must send and that are included in the signature
91
+ * (content-type, content-length, …). Host is always signed and omitted
92
+ * from the returned headers (fetch sets it from the URL).
93
+ */
94
+ signedHeaders?: Record<string, string>;
95
+ now: Date;
96
+ }
97
+ export interface PresignedS3Url {
98
+ url: string;
99
+ /** Headers the client must include (signed set minus host). */
100
+ headers: Record<string, string>;
101
+ }
102
+ /**
103
+ * Build a query-string SigV4 presigned URL for R2/S3.
104
+ *
105
+ * Uses UNSIGNED-PAYLOAD so the client does not need to pre-hash the body.
106
+ * Credential and signature ride in the query string; the client sends only
107
+ * the signed non-host headers (e.g. content-type) with the request.
108
+ */
109
+ export declare function signS3PresignedUrl(input: SignS3PresignedInput): PresignedS3Url;
76
110
  //# sourceMappingURL=_sigv4.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"_sigv4.d.ts","sourceRoot":"","sources":["../../src/storage/_sigv4.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAOH,iFAAiF;AACjF,eAAO,MAAM,YAAY,qEAAqE,CAAC;AAE/F,gEAAgE;AAChE,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,MAAM,CAE3D;AAQD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,GAAG,MAAM,CAaxE;AAQD,+EAA+E;AAC/E,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,wDAAwD;IACxD,aAAa,EAAE,MAAM,CAAC;IACtB,mEAAmE;IACnE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;IAC1B,4EAA4E;IAC5E,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,cAAc,GAAG,WAAW,CAkC/D;AAED,6CAA6C;AAC7C,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,KAAK,GAAG,KAAK,GAAG,QAAQ,CAAC;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,uEAAuE;IACvE,WAAW,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,GAAG,EAAE,IAAI,CAAC;CACX;AAED,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,WAAW,GAAG,eAAe,CAmDjE"}
1
+ {"version":3,"file":"_sigv4.d.ts","sourceRoot":"","sources":["../../src/storage/_sigv4.ts"],"names":[],"mappings":"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"}
@@ -81,6 +81,8 @@ export function computeSigV4(input) {
81
81
  const signature = createHmac('sha256', kSigning).update(stringToSign, 'utf8').digest('hex');
82
82
  return { signature, signedHeaders, scope, canonicalQuery };
83
83
  }
84
+ /** Payload hash token for presigned URLs (body hash unknown at sign time). */
85
+ export const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
84
86
  /**
85
87
  * Build a signed R2 request: a path-style URL + AWS4-HMAC-SHA256 Authorization
86
88
  * header. The `host` header is signed but not returned — fetch/undici sets it
@@ -132,3 +134,54 @@ export function signS3Request(input) {
132
134
  const url = `https://${host}${canonicalPath}${canonicalQuery ? `?${canonicalQuery}` : ''}`;
133
135
  return { url, headers };
134
136
  }
137
+ /**
138
+ * Build a query-string SigV4 presigned URL for R2/S3.
139
+ *
140
+ * Uses UNSIGNED-PAYLOAD so the client does not need to pre-hash the body.
141
+ * Credential and signature ride in the query string; the client sends only
142
+ * the signed non-host headers (e.g. content-type) with the request.
143
+ */
144
+ export function signS3PresignedUrl(input) {
145
+ const host = `${input.accountId}.r2.cloudflarestorage.com`;
146
+ const { amzDate, dateStamp } = toAmzDate(input.now);
147
+ const scope = `${dateStamp}/${input.region}/${S3_SERVICE}/aws4_request`;
148
+ const encodedKey = input.key
149
+ .split('/')
150
+ .map((segment) => awsUriEncode(segment, false))
151
+ .join('/');
152
+ const canonicalPath = `/${awsUriEncode(input.bucket, false)}/${encodedKey}`;
153
+ const clientHeaders = {};
154
+ const headersToSign = { host };
155
+ for (const [name, value] of Object.entries(input.signedHeaders ?? {})) {
156
+ const lower = name.toLowerCase();
157
+ if (lower === 'host')
158
+ continue;
159
+ headersToSign[lower] = value;
160
+ clientHeaders[lower] = value;
161
+ }
162
+ const signedHeaderNames = Object.keys(headersToSign).sort().join(';');
163
+ const query = [
164
+ ['X-Amz-Algorithm', ALGORITHM],
165
+ ['X-Amz-Credential', `${input.accessKeyId}/${scope}`],
166
+ ['X-Amz-Date', amzDate],
167
+ ['X-Amz-Expires', String(input.expiresInSeconds)],
168
+ ['X-Amz-SignedHeaders', signedHeaderNames],
169
+ ];
170
+ const { signature, canonicalQuery } = computeSigV4({
171
+ method: input.method,
172
+ canonicalPath,
173
+ query,
174
+ headersToSign,
175
+ payloadHash: UNSIGNED_PAYLOAD,
176
+ amzDate,
177
+ region: input.region,
178
+ service: S3_SERVICE,
179
+ accessKeyId: input.accessKeyId,
180
+ secretAccessKey: input.secretAccessKey,
181
+ });
182
+ const fullQuery = `${canonicalQuery}&${awsUriEncode('X-Amz-Signature', true)}=${awsUriEncode(signature, true)}`;
183
+ return {
184
+ url: `https://${host}${canonicalPath}?${fullQuery}`,
185
+ headers: clientHeaders,
186
+ };
187
+ }
@@ -3,7 +3,7 @@ export { createMockProvider } from './mock.js';
3
3
  export type { ObjectStorageConfig } from './object-storage.js';
4
4
  export { objectStorage } from './object-storage.js';
5
5
  export { createR2Provider } from './r2.js';
6
- export type { ListItem, ListOptions, ListResult, PutOptions, PutResult, R2Config, StorageConfig, StorageProvider, } from './types.js';
6
+ export type { HeadObjectResult, ListItem, ListOptions, ListResult, PresignPutOptions, PresignPutResult, PutOptions, PutResult, R2Config, StorageConfig, StorageProvider, } from './types.js';
7
7
  /**
8
8
  * Construct a StorageProvider from a tagged config.
9
9
  *
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/storage/index.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAEjE,OAAO,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAC/C,YAAY,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAK/D,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAIpD,OAAO,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAC3C,YAAY,EACV,QAAQ,EACR,WAAW,EACX,UAAU,EACV,UAAU,EACV,SAAS,EACT,QAAQ,EACR,aAAa,EACb,eAAe,GAChB,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,aAAa,GAAG,eAAe,CAcpE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/storage/index.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAEjE,OAAO,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAC/C,YAAY,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAK/D,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAIpD,OAAO,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAC3C,YAAY,EACV,gBAAgB,EAChB,QAAQ,EACR,WAAW,EACX,UAAU,EACV,iBAAiB,EACjB,gBAAgB,EAChB,UAAU,EACV,SAAS,EACT,QAAQ,EACR,aAAa,EACb,eAAe,GAChB,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,aAAa,GAAG,eAAe,CAcpE"}
@@ -3,9 +3,8 @@
3
3
  //
4
4
  // Phase 1 (#959): types.ts + index.ts type re-exports.
5
5
  // Phase 2a (#959 follow-up): r2 + mock providers + createStorage factory.
6
- // Phase 2b (#959 follow-up): apps/server media-route cutover onto R2.
7
- // #1644: legacy Vercel Blob StorageProvider removed once R2 was confirmed in
8
- // every production environment — R2 is now the sole non-mock backend.
6
+ // Phase 2b media-route cutover onto R2 (server) + #1644 Vercel Blob removal.
7
+ // Phase 2b presign (GAP-215): createPresignedPutUrl + headObject + getObjectRange.
9
8
  import { createMockProvider } from './mock.js';
10
9
  import { createR2Provider } from './r2.js';
11
10
  export { createMockProvider } from './mock.js';
@@ -8,9 +8,9 @@
8
8
  * `mock://` scheme so callers can distinguish mock results from real ones
9
9
  * without parsing host names.
10
10
  *
11
- * GAP-208 Phase 2a (2026-05-18).
11
+ * GAP-208 Phase 2a (2026-05-18). Phase 2b (GAP-215): presigned PUT + head + range.
12
12
  */
13
- import type { ListOptions, ListResult, PutOptions, PutResult, StorageProvider } from './types.js';
13
+ import type { HeadObjectResult, ListOptions, ListResult, PresignPutOptions, PresignPutResult, PutOptions, PutResult, StorageProvider } from './types.js';
14
14
  interface MockEntry {
15
15
  body: Uint8Array;
16
16
  contentType: string;
@@ -22,6 +22,9 @@ declare class MockProvider implements StorageProvider {
22
22
  readonly provider = "mock";
23
23
  private readonly store;
24
24
  put(key: string, data: Blob | ArrayBuffer | Uint8Array | ReadableStream<Uint8Array>, opts?: PutOptions): Promise<PutResult>;
25
+ createPresignedPutUrl(opts: PresignPutOptions): Promise<PresignPutResult>;
26
+ headObject(key: string): Promise<HeadObjectResult>;
27
+ getObjectRange(key: string, start: number, endInclusive: number): Promise<Uint8Array>;
25
28
  del(keyOrUrl: string): Promise<void>;
26
29
  list(opts?: ListOptions): Promise<ListResult>;
27
30
  /** Test helper — read the raw bytes back. Not part of StorageProvider. */
@@ -1 +1 @@
1
- {"version":3,"file":"mock.d.ts","sourceRoot":"","sources":["../../src/storage/mock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EAEV,WAAW,EACX,UAAU,EACV,UAAU,EACV,SAAS,EACT,eAAe,EAChB,MAAM,YAAY,CAAC;AAMpB,UAAU,SAAS;IACjB,IAAI,EAAE,UAAU,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,UAAU,EAAE,IAAI,CAAC;CAClB;AAED,cAAM,YAAa,YAAW,eAAe;IAC3C,QAAQ,CAAC,QAAQ,UAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgC;IAEhD,GAAG,CACP,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,IAAI,GAAG,WAAW,GAAG,UAAU,GAAG,cAAc,CAAC,UAAU,CAAC,EAClE,IAAI,CAAC,EAAE,UAAU,GAChB,OAAO,CAAC,SAAS,CAAC;IAiBf,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKpC,IAAI,CAAC,IAAI,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC;IAgCnD,0EAA0E;IAC1E,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS;IAIzC,wEAAwE;IACxE,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS;IAI3C,0EAA0E;IAC1E,IAAI,IAAI,MAAM;IAId,0EAA0E;IAC1E,KAAK,IAAI,IAAI;IAIb,OAAO,CAAC,UAAU;CAMnB;AAED,wBAAgB,kBAAkB,IAAI,YAAY,CAEjD;AAED,YAAY,EAAE,YAAY,EAAE,CAAC"}
1
+ {"version":3,"file":"mock.d.ts","sourceRoot":"","sources":["../../src/storage/mock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EACV,gBAAgB,EAEhB,WAAW,EACX,UAAU,EACV,iBAAiB,EACjB,gBAAgB,EAChB,UAAU,EACV,SAAS,EACT,eAAe,EAChB,MAAM,YAAY,CAAC;AAOpB,UAAU,SAAS;IACjB,IAAI,EAAE,UAAU,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,UAAU,EAAE,IAAI,CAAC;CAClB;AAED,cAAM,YAAa,YAAW,eAAe;IAC3C,QAAQ,CAAC,QAAQ,UAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgC;IAEhD,GAAG,CACP,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,IAAI,GAAG,WAAW,GAAG,UAAU,GAAG,cAAc,CAAC,UAAU,CAAC,EAClE,IAAI,CAAC,EAAE,UAAU,GAChB,OAAO,CAAC,SAAS,CAAC;IAiBf,qBAAqB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC;IA6BzE,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAYlD,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAkBrF,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKpC,IAAI,CAAC,IAAI,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC;IAgCnD,0EAA0E;IAC1E,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS;IAIzC,wEAAwE;IACxE,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS;IAI3C,0EAA0E;IAC1E,IAAI,IAAI,MAAM;IAId,0EAA0E;IAC1E,KAAK,IAAI,IAAI;IAIb,OAAO,CAAC,UAAU;CAMnB;AAED,wBAAgB,kBAAkB,IAAI,YAAY,CAEjD;AAED,YAAY,EAAE,YAAY,EAAE,CAAC"}
@@ -8,12 +8,13 @@
8
8
  * `mock://` scheme so callers can distinguish mock results from real ones
9
9
  * without parsing host names.
10
10
  *
11
- * GAP-208 Phase 2a (2026-05-18).
11
+ * GAP-208 Phase 2a (2026-05-18). Phase 2b (GAP-215): presigned PUT + head + range.
12
12
  */
13
13
  import { toUint8Array } from './_helpers.js';
14
14
  const PROVIDER_TAG = 'mock';
15
15
  const MOCK_URL_SCHEME = 'mock://storage';
16
16
  const DEFAULT_LIST_LIMIT = 1000;
17
+ const DEFAULT_PRESIGN_EXPIRES_SECONDS = 900;
17
18
  class MockProvider {
18
19
  provider = PROVIDER_TAG;
19
20
  store = new Map();
@@ -33,6 +34,58 @@ class MockProvider {
33
34
  provider: PROVIDER_TAG,
34
35
  };
35
36
  }
37
+ async createPresignedPutUrl(opts) {
38
+ const expiresInSeconds = opts.expiresInSeconds ?? DEFAULT_PRESIGN_EXPIRES_SECONDS;
39
+ if (expiresInSeconds <= 0) {
40
+ throw new Error('createPresignedPutUrl: expiresInSeconds must be positive');
41
+ }
42
+ const now = new Date();
43
+ const expiresAt = new Date(now.getTime() + expiresInSeconds * 1000);
44
+ const headers = {
45
+ 'content-type': opts.contentType,
46
+ };
47
+ if (opts.contentLength !== undefined) {
48
+ headers['content-length'] = String(opts.contentLength);
49
+ }
50
+ // Synthetic presigned URL. Tests that exercise the full client flow call
51
+ // put() with the same key after "uploading"; route tests mock this method.
52
+ const params = new URLSearchParams({
53
+ expires: String(Math.floor(expiresAt.getTime() / 1000)),
54
+ 'content-type': opts.contentType,
55
+ });
56
+ return {
57
+ url: `${MOCK_URL_SCHEME}/presign/${opts.key}?${params.toString()}`,
58
+ headers,
59
+ key: opts.key,
60
+ expiresAt,
61
+ };
62
+ }
63
+ async headObject(key) {
64
+ const entry = this.store.get(key);
65
+ if (!entry) {
66
+ throw new Error(`mock HEAD failed for "${key}": NoSuchKey object not found`);
67
+ }
68
+ return {
69
+ size: entry.body.byteLength,
70
+ contentType: entry.contentType,
71
+ url: `${MOCK_URL_SCHEME}/${key}`,
72
+ };
73
+ }
74
+ async getObjectRange(key, start, endInclusive) {
75
+ if (start < 0 || endInclusive < start) {
76
+ throw new Error(`getObjectRange: invalid range ${start}-${endInclusive} (start must be >= 0 and endInclusive >= start)`);
77
+ }
78
+ const entry = this.store.get(key);
79
+ if (!entry) {
80
+ throw new Error(`mock GET range failed for "${key}": NoSuchKey object not found`);
81
+ }
82
+ // Inclusive end, clamped to body length (mirrors S3 range semantics).
83
+ const end = Math.min(endInclusive, entry.body.byteLength - 1);
84
+ if (start >= entry.body.byteLength) {
85
+ return new Uint8Array(0);
86
+ }
87
+ return entry.body.slice(start, end + 1);
88
+ }
36
89
  async del(keyOrUrl) {
37
90
  const key = this.extractKey(keyOrUrl);
38
91
  this.store.delete(key);
@@ -12,13 +12,15 @@
12
12
  *
13
13
  * GAP-208 Phase 2a (2026-05-18). Phase 1 (interface + types) shipped in #959.
14
14
  * Native client (dropping @aws-sdk/client-s3) landed 2026-06-09.
15
+ * Phase 2b (GAP-215): createPresignedPutUrl + headObject + getObjectRange for
16
+ * direct-to-R2 media uploads.
15
17
  *
16
18
  * Server-only. Do NOT import from client-side code or edge runtime.
17
19
  *
18
- * Known limitation (unchanged from the SDK version): request bodies are
19
- * buffered fully in memory (see toUint8Array) so the payload can be hashed for
20
- * SigV4. Streaming uploads (STREAMING-AWS4-HMAC-SHA256-PAYLOAD) are a future
21
- * enhancement; presigned/private URLs remain Phase 2b.
20
+ * Known limitation: request bodies for server-side put() are buffered fully in
21
+ * memory (see toUint8Array) so the payload can be hashed for SigV4. Streaming
22
+ * uploads (STREAMING-AWS4-HMAC-SHA256-PAYLOAD) are a future enhancement.
23
+ * Direct client uploads use createPresignedPutUrl and never buffer in the API.
22
24
  */
23
25
  import type { R2Config, StorageProvider } from './types.js';
24
26
  export declare function createR2Provider(config: R2Config): StorageProvider;
@@ -1 +1 @@
1
- {"version":3,"file":"r2.d.ts","sourceRoot":"","sources":["../../src/storage/r2.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAKH,OAAO,KAAK,EAMV,QAAQ,EACR,eAAe,EAChB,MAAM,YAAY,CAAC;AAkKpB,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,QAAQ,GAAG,eAAe,CAElE"}
1
+ {"version":3,"file":"r2.d.ts","sourceRoot":"","sources":["../../src/storage/r2.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAKH,OAAO,KAAK,EASV,QAAQ,EACR,eAAe,EAChB,MAAM,YAAY,CAAC;AA6QpB,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,QAAQ,GAAG,eAAe,CAElE"}
@@ -12,20 +12,23 @@
12
12
  *
13
13
  * GAP-208 Phase 2a (2026-05-18). Phase 1 (interface + types) shipped in #959.
14
14
  * Native client (dropping @aws-sdk/client-s3) landed 2026-06-09.
15
+ * Phase 2b (GAP-215): createPresignedPutUrl + headObject + getObjectRange for
16
+ * direct-to-R2 media uploads.
15
17
  *
16
18
  * Server-only. Do NOT import from client-side code or edge runtime.
17
19
  *
18
- * Known limitation (unchanged from the SDK version): request bodies are
19
- * buffered fully in memory (see toUint8Array) so the payload can be hashed for
20
- * SigV4. Streaming uploads (STREAMING-AWS4-HMAC-SHA256-PAYLOAD) are a future
21
- * enhancement; presigned/private URLs remain Phase 2b.
20
+ * Known limitation: request bodies for server-side put() are buffered fully in
21
+ * memory (see toUint8Array) so the payload can be hashed for SigV4. Streaming
22
+ * uploads (STREAMING-AWS4-HMAC-SHA256-PAYLOAD) are a future enhancement.
23
+ * Direct client uploads use createPresignedPutUrl and never buffer in the API.
22
24
  */
23
25
  import { toUint8Array } from './_helpers.js';
24
- import { EMPTY_SHA256, sha256Hex, signS3Request } from './_sigv4.js';
26
+ import { EMPTY_SHA256, sha256Hex, signS3PresignedUrl, signS3Request } from './_sigv4.js';
25
27
  import { parseListObjectsV2, s3ErrorFields } from './_xml.js';
26
28
  const PROVIDER_TAG = 'r2';
27
29
  const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
28
30
  const DEFAULT_LIST_LIMIT = 1000;
31
+ const DEFAULT_PRESIGN_EXPIRES_SECONDS = 900;
29
32
  const REGION = 'auto';
30
33
  class R2Provider {
31
34
  provider = PROVIDER_TAG;
@@ -36,9 +39,9 @@ class R2Provider {
36
39
  publicBaseUrl;
37
40
  constructor(config) {
38
41
  if (!config.publicBaseUrl) {
39
- throw new Error('R2 provider requires R2Config.publicBaseUrl. Presigned/private URLs ' +
40
- 'are not supported in Phase 2a. Set publicBaseUrl to either a bound ' +
41
- "custom domain (e.g. 'https://media.revealui.com') or the R2 dev URL " +
42
+ throw new Error('R2 provider requires R2Config.publicBaseUrl for public media URLs after ' +
43
+ 'upload. Set publicBaseUrl to either a bound custom domain ' +
44
+ "(e.g. 'https://media.revealui.com') or the R2 dev URL " +
42
45
  "('https://<account-id>.r2.cloudflarestorage.com/<bucket>').");
43
46
  }
44
47
  this.accountId = config.accountId;
@@ -49,9 +52,9 @@ class R2Provider {
49
52
  }
50
53
  async put(key, data, opts) {
51
54
  if (opts?.access === 'private') {
52
- throw new Error("R2 provider Phase 2a does not support access: 'private'. All objects " +
53
- 'are written under the publicBaseUrl. Presigned/private-URL support ' +
54
- 'lands in Phase 2b.');
55
+ throw new Error("R2 provider does not support access: 'private' for put(). Use " +
56
+ 'createPresignedPutUrl for client direct uploads; public object URLs ' +
57
+ 'still use publicBaseUrl after confirm.');
55
58
  }
56
59
  const body = await toUint8Array(data);
57
60
  const extraHeaders = {
@@ -90,11 +93,99 @@ class R2Provider {
90
93
  }
91
94
  return {
92
95
  key,
93
- url: `${this.publicBaseUrl}/${key}`,
96
+ url: this.objectPublicUrl(key),
94
97
  size: body.byteLength,
95
98
  provider: PROVIDER_TAG,
96
99
  };
97
100
  }
101
+ async createPresignedPutUrl(opts) {
102
+ const expiresInSeconds = opts.expiresInSeconds ?? DEFAULT_PRESIGN_EXPIRES_SECONDS;
103
+ if (expiresInSeconds <= 0) {
104
+ throw new Error('createPresignedPutUrl: expiresInSeconds must be positive');
105
+ }
106
+ const signedHeaders = {
107
+ 'content-type': opts.contentType,
108
+ };
109
+ if (opts.contentLength !== undefined) {
110
+ signedHeaders['content-length'] = String(opts.contentLength);
111
+ }
112
+ const now = new Date();
113
+ const { url, headers } = signS3PresignedUrl({
114
+ method: 'PUT',
115
+ accountId: this.accountId,
116
+ bucket: this.bucket,
117
+ key: opts.key,
118
+ region: REGION,
119
+ accessKeyId: this.accessKeyId,
120
+ secretAccessKey: this.secretAccessKey,
121
+ expiresInSeconds,
122
+ signedHeaders,
123
+ now,
124
+ });
125
+ return {
126
+ url,
127
+ headers,
128
+ key: opts.key,
129
+ expiresAt: new Date(now.getTime() + expiresInSeconds * 1000),
130
+ };
131
+ }
132
+ async headObject(key) {
133
+ const { url, headers } = signS3Request({
134
+ method: 'HEAD',
135
+ accountId: this.accountId,
136
+ bucket: this.bucket,
137
+ key,
138
+ region: REGION,
139
+ accessKeyId: this.accessKeyId,
140
+ secretAccessKey: this.secretAccessKey,
141
+ payloadHash: EMPTY_SHA256,
142
+ now: new Date(),
143
+ });
144
+ const response = await fetch(url, { method: 'HEAD', headers });
145
+ if (response.status === 404) {
146
+ throw new Error(`R2 HEAD failed for "${key}": NoSuchKey object not found`);
147
+ }
148
+ if (!response.ok) {
149
+ throw await storageError('HEAD', key, response);
150
+ }
151
+ const lengthHeader = response.headers.get('content-length');
152
+ const size = lengthHeader === null ? 0 : Number(lengthHeader);
153
+ if (!Number.isFinite(size) || size < 0) {
154
+ throw new Error(`R2 HEAD failed for "${key}": invalid content-length`);
155
+ }
156
+ return {
157
+ size,
158
+ contentType: response.headers.get('content-type') ?? DEFAULT_CONTENT_TYPE,
159
+ url: this.objectPublicUrl(key),
160
+ };
161
+ }
162
+ async getObjectRange(key, start, endInclusive) {
163
+ if (start < 0 || endInclusive < start) {
164
+ throw new Error(`getObjectRange: invalid range ${start}-${endInclusive} (start must be >= 0 and endInclusive >= start)`);
165
+ }
166
+ const { url, headers } = signS3Request({
167
+ method: 'GET',
168
+ accountId: this.accountId,
169
+ bucket: this.bucket,
170
+ key,
171
+ region: REGION,
172
+ accessKeyId: this.accessKeyId,
173
+ secretAccessKey: this.secretAccessKey,
174
+ payloadHash: EMPTY_SHA256,
175
+ extraHeaders: { range: `bytes=${start}-${endInclusive}` },
176
+ now: new Date(),
177
+ });
178
+ const response = await fetch(url, { method: 'GET', headers });
179
+ if (response.status === 404) {
180
+ throw new Error(`R2 GET range failed for "${key}": NoSuchKey object not found`);
181
+ }
182
+ // 206 Partial Content is the expected success; some backends return 200
183
+ // with the full object when range is unsupported — still accept it.
184
+ if (!response.ok && response.status !== 206) {
185
+ throw await storageError('GET range', key, response);
186
+ }
187
+ return new Uint8Array(await response.arrayBuffer());
188
+ }
98
189
  async del(keyOrUrl) {
99
190
  const key = this.extractKey(keyOrUrl);
100
191
  const { url, headers } = signS3Request({
@@ -139,7 +230,7 @@ class R2Provider {
139
230
  const parsed = parseListObjectsV2(await response.text());
140
231
  const items = parsed.objects.map((entry) => ({
141
232
  key: entry.key,
142
- url: `${this.publicBaseUrl}/${entry.key}`,
233
+ url: this.objectPublicUrl(entry.key),
143
234
  size: entry.size,
144
235
  uploadedAt: entry.lastModified,
145
236
  }));
@@ -149,6 +240,9 @@ class R2Provider {
149
240
  hasMore: parsed.isTruncated,
150
241
  };
151
242
  }
243
+ objectPublicUrl(key) {
244
+ return `${this.publicBaseUrl}/${key}`;
245
+ }
152
246
  extractKey(keyOrUrl) {
153
247
  const parsed = tryParseUrl(keyOrUrl);
154
248
  if (!parsed) {
@@ -20,9 +20,58 @@ export interface StorageProvider {
20
20
  del(keyOrUrl: string): Promise<void>;
21
21
  /** List items, optionally filtered by key prefix. Used by health probes + admin browsing. */
22
22
  list(opts?: ListOptions): Promise<ListResult>;
23
+ /**
24
+ * Issue a short-lived presigned PUT URL so clients upload bytes direct to the
25
+ * backend (GAP-215 / Phase 2b). File bytes never buffer in the API function.
26
+ */
27
+ createPresignedPutUrl(opts: PresignPutOptions): Promise<PresignPutResult>;
28
+ /**
29
+ * HEAD an object. Used by the media confirm endpoint to re-check size and
30
+ * content-type after a direct-to-storage upload.
31
+ */
32
+ headObject(key: string): Promise<HeadObjectResult>;
33
+ /**
34
+ * GET a byte range of an object (inclusive end). Used to re-check magic
35
+ * bytes on confirm without downloading the whole file.
36
+ */
37
+ getObjectRange(key: string, start: number, endInclusive: number): Promise<Uint8Array>;
23
38
  /** Provider tag (e.g. "r2", "mock") — exposed so consumers can adapt URL handling. */
24
39
  readonly provider: string;
25
40
  }
41
+ export interface PresignPutOptions {
42
+ /** Storage key the client will write (provider-relative, e.g. "media/uuid.jpg"). */
43
+ key: string;
44
+ /** MIME type the client must send as Content-Type (signed into the URL). */
45
+ contentType: string;
46
+ /**
47
+ * Optional declared Content-Length. When set, signed into the request so the
48
+ * client cannot upload a larger body. Browser fetch cannot set Content-Length
49
+ * manually, so media direct-upload leaves this unset and re-checks size via
50
+ * headObject on confirm.
51
+ */
52
+ contentLength?: number;
53
+ /** URL lifetime in seconds. Defaults to provider-specific value (typically 900). */
54
+ expiresInSeconds?: number;
55
+ }
56
+ export interface PresignPutResult {
57
+ /** Fully-qualified presigned PUT URL (query-string SigV4). */
58
+ url: string;
59
+ /**
60
+ * Headers the client MUST send with the PUT (at least content-type). Values
61
+ * are already signed into the URL's X-Amz-SignedHeaders set.
62
+ */
63
+ headers: Record<string, string>;
64
+ /** Storage key the URL writes to (echo of PresignPutOptions.key). */
65
+ key: string;
66
+ /** Absolute expiry of the URL. */
67
+ expiresAt: Date;
68
+ }
69
+ export interface HeadObjectResult {
70
+ size: number;
71
+ contentType: string;
72
+ /** Public (or mock) URL the object is reachable at for GET. */
73
+ url: string;
74
+ }
26
75
  export interface PutOptions {
27
76
  /** MIME type — written to the object's Content-Type header. Defaults to "application/octet-stream". */
28
77
  contentType?: string;
@@ -93,10 +142,11 @@ export interface R2Config {
93
142
  /** Bucket name (per-account-globally-unique within R2). */
94
143
  bucket: string;
95
144
  /**
96
- * Optional public base URL for objects (e.g. "https://media.revealui.com" if a
97
- * custom domain is bound, or "https://<account-id>.r2.cloudflarestorage.com/<bucket>"
98
- * for the dev URL). When set, PutResult.url uses this base. When omitted, PutResult.url
99
- * falls back to a presigned URL (private bucket pattern).
145
+ * Public base URL for objects (e.g. "https://media.revealui.com" if a custom
146
+ * domain is bound, or the R2 dev URL). Required: PutResult.url, HeadObjectResult.url,
147
+ * and media rows after confirm use this base. Presigned PUT URLs themselves
148
+ * target the S3 API endpoint and do not need this, but the final public media
149
+ * URL still does.
100
150
  */
101
151
  publicBaseUrl?: string;
102
152
  }