@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.
- package/dist/client/admin/components/AdminDashboard.d.ts.map +1 -1
- package/dist/client/admin/components/AdminDashboard.js +103 -6
- package/dist/client/admin/components/DocumentForm.d.ts.map +1 -1
- package/dist/client/admin/components/DocumentForm.js +5 -1
- package/dist/client/admin/page.d.ts.map +1 -1
- package/dist/client/admin/page.js +1 -1
- package/dist/client/admin/utils/apiClient.d.ts.map +1 -1
- package/dist/client/admin/utils/apiClient.js +27 -1
- package/dist/client/admin/utils/csrf.d.ts +4 -3
- package/dist/client/admin/utils/csrf.d.ts.map +1 -1
- package/dist/client/admin/utils/csrf.js +4 -3
- package/dist/client/richtext/components/ImageUploadButton.d.ts.map +1 -1
- package/dist/client/richtext/components/ImageUploadButton.js +17 -4
- package/dist/client/richtext/components/upload.d.ts +7 -0
- package/dist/client/richtext/components/upload.d.ts.map +1 -1
- package/dist/client/richtext/components/upload.js +10 -0
- package/dist/client/richtext/nodes/DecoratorBlockNode.d.ts +4 -0
- package/dist/client/richtext/nodes/DecoratorBlockNode.d.ts.map +1 -1
- package/dist/client/richtext/nodes/DecoratorBlockNode.js +12 -0
- package/dist/features.d.ts +2 -0
- package/dist/features.d.ts.map +1 -1
- package/dist/features.js +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/license/mint-client.d.ts +6 -0
- package/dist/license/mint-client.d.ts.map +1 -1
- package/dist/license/mint-client.js +34 -16
- package/dist/license.d.ts +6 -0
- package/dist/license.d.ts.map +1 -1
- package/dist/license.js +15 -0
- package/dist/monitoring/alerts.d.ts +6 -1
- package/dist/monitoring/alerts.d.ts.map +1 -1
- package/dist/monitoring/alerts.js +6 -1
- package/dist/observability/alerts.d.ts +7 -2
- package/dist/observability/alerts.d.ts.map +1 -1
- package/dist/observability/alerts.js +7 -2
- package/dist/observability/capture.d.ts +63 -0
- package/dist/observability/capture.d.ts.map +1 -0
- package/dist/observability/capture.js +125 -0
- package/dist/observability/cron-failure-alert.d.ts +59 -0
- package/dist/observability/cron-failure-alert.d.ts.map +1 -0
- package/dist/observability/cron-failure-alert.js +109 -0
- package/dist/observability/index.d.ts +2 -0
- package/dist/observability/index.d.ts.map +1 -1
- package/dist/observability/index.js +2 -0
- package/dist/revforge-license-boot.d.ts +35 -0
- package/dist/revforge-license-boot.d.ts.map +1 -0
- package/dist/revforge-license-boot.js +115 -0
- package/dist/storage/_sigv4.d.ts +35 -1
- package/dist/storage/_sigv4.d.ts.map +1 -1
- package/dist/storage/_sigv4.js +53 -0
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +2 -3
- package/dist/storage/mock.d.ts +5 -2
- package/dist/storage/mock.d.ts.map +1 -1
- package/dist/storage/mock.js +54 -1
- package/dist/storage/r2.d.ts +6 -4
- package/dist/storage/r2.d.ts.map +1 -1
- package/dist/storage/r2.js +107 -13
- package/dist/storage/types.d.ts +54 -4
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/vite/index.d.ts +2 -0
- package/dist/vite/index.d.ts.map +1 -0
- package/dist/vite/index.js +6 -0
- package/dist/vite/withRevealUI.d.ts +62 -0
- package/dist/vite/withRevealUI.d.ts.map +1 -0
- package/dist/vite/withRevealUI.js +94 -0
- 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
|
+
}
|
package/dist/storage/_sigv4.d.ts
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"_sigv4.d.ts","sourceRoot":"","sources":["../../src/storage/_sigv4.ts"],"names":[],"mappings":"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"}
|
package/dist/storage/_sigv4.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/storage/index.d.ts
CHANGED
|
@@ -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":"
|
|
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"}
|
package/dist/storage/index.js
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
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';
|
package/dist/storage/mock.d.ts
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/storage/mock.js
CHANGED
|
@@ -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);
|
package/dist/storage/r2.d.ts
CHANGED
|
@@ -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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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;
|
package/dist/storage/r2.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"r2.d.ts","sourceRoot":"","sources":["../../src/storage/r2.ts"],"names":[],"mappings":"AAAA
|
|
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"}
|
package/dist/storage/r2.js
CHANGED
|
@@ -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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
40
|
-
'
|
|
41
|
-
"
|
|
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
|
|
53
|
-
'
|
|
54
|
-
'
|
|
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:
|
|
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:
|
|
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) {
|
package/dist/storage/types.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
}
|