@knowledgefutures/sdk 0.0.0-stage → 0.1.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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +24 -2
  3. package/dist/analytics/beacon.d.ts +25 -0
  4. package/dist/analytics/beacon.js +86 -0
  5. package/dist/analytics/binding.d.ts +24 -0
  6. package/dist/analytics/binding.js +46 -0
  7. package/dist/analytics/collector.d.ts +10 -0
  8. package/dist/analytics/collector.js +10 -0
  9. package/dist/analytics/cubes.d.ts +96 -0
  10. package/dist/analytics/cubes.js +213 -0
  11. package/dist/analytics/envelope.d.ts +135 -0
  12. package/dist/analytics/envelope.js +231 -0
  13. package/dist/analytics/errors.d.ts +24 -0
  14. package/dist/analytics/errors.js +20 -0
  15. package/dist/analytics/index.d.ts +38 -0
  16. package/dist/analytics/index.js +98 -0
  17. package/dist/analytics/memory.d.ts +35 -0
  18. package/dist/analytics/memory.js +66 -0
  19. package/dist/analytics/testing.d.ts +6 -0
  20. package/dist/analytics/testing.js +6 -0
  21. package/dist/analytics/types.d.ts +22 -0
  22. package/dist/analytics/types.js +1 -0
  23. package/dist/storage/binding.d.ts +76 -0
  24. package/dist/storage/binding.js +97 -0
  25. package/dist/storage/brokered.d.ts +21 -0
  26. package/dist/storage/brokered.js +94 -0
  27. package/dist/storage/conformance.d.ts +24 -0
  28. package/dist/storage/conformance.js +311 -0
  29. package/dist/storage/errors.d.ts +27 -0
  30. package/dist/storage/errors.js +21 -0
  31. package/dist/storage/index.d.ts +34 -0
  32. package/dist/storage/index.js +77 -0
  33. package/dist/storage/local.d.ts +26 -0
  34. package/dist/storage/local.js +57 -0
  35. package/dist/storage/s3.d.ts +22 -0
  36. package/dist/storage/s3.js +254 -0
  37. package/dist/storage/shared.d.ts +45 -0
  38. package/dist/storage/shared.js +167 -0
  39. package/dist/storage/sigv4.d.ts +50 -0
  40. package/dist/storage/sigv4.js +128 -0
  41. package/dist/storage/testing.d.ts +25 -0
  42. package/dist/storage/testing.js +242 -0
  43. package/dist/storage/types.d.ts +122 -0
  44. package/dist/storage/types.js +13 -0
  45. package/package.json +30 -3
@@ -0,0 +1,167 @@
1
+ import { StorageError } from './errors.js';
2
+ /**
3
+ * Rules every implementation applies before it talks to a backend. They live
4
+ * here, once, so "what is invalid" cannot differ between R2, RustFS and the
5
+ * in-memory store — that difference would be exactly the staging/production
6
+ * surprise the conformance suite exists to prevent.
7
+ */
8
+ const utf8 = new TextEncoder();
9
+ export const MAX_KEY_BYTES = 1024;
10
+ export const MAX_EXPIRES_IN = 604_800;
11
+ /** Default lifetimes of a signed upload and a signed download, in seconds. */
12
+ export const UPLOAD_EXPIRES_IN = 600;
13
+ export const DOWNLOAD_EXPIRES_IN = 300;
14
+ const METADATA_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
15
+ const MAX_METADATA_BYTES = 2048;
16
+ /**
17
+ * A key is 1–1024 UTF-8 bytes with no leading `/`, no `.` or `..` segment and
18
+ * no control characters. The segment rule is not S3's: a URL path normalizes
19
+ * `a/../b` to `b` before it is sent, so such a key would silently name a
20
+ * different object.
21
+ */
22
+ export function checkKey(key) {
23
+ if (typeof key !== 'string' || key.length === 0)
24
+ throw invalid('a key must be a non-empty string');
25
+ if (utf8.encode(key).length > MAX_KEY_BYTES)
26
+ throw invalid(`a key is at most ${MAX_KEY_BYTES} bytes`);
27
+ if (key.startsWith('/'))
28
+ throw invalid(`"${key}": a key must not start with "/"`);
29
+ // oxlint-disable-next-line no-control-regex
30
+ if (/[\u0000-\u001f\u007f]/.test(key))
31
+ throw invalid(`"${key}": a key must not contain control characters`);
32
+ for (const segment of key.split('/')) {
33
+ if (segment === '.' || segment === '..') {
34
+ throw invalid(`"${key}": a key must not contain a "." or ".." segment`);
35
+ }
36
+ }
37
+ }
38
+ export function checkPrefix(prefix) {
39
+ if (prefix === undefined || prefix === '')
40
+ return;
41
+ checkKey(prefix);
42
+ }
43
+ export function checkMetadata(metadata) {
44
+ const out = {};
45
+ let bytes = 0;
46
+ for (const [name, value] of Object.entries(metadata ?? {})) {
47
+ if (!METADATA_NAME.test(name)) {
48
+ throw invalid(`metadata name "${name}": lowercase letters, digits and "-", at most 64`);
49
+ }
50
+ if (typeof value !== 'string')
51
+ throw invalid(`metadata "${name}" must be a string`);
52
+ // Header values: printable ASCII only, which is what every backend round-trips intact.
53
+ if (!/^[\u0020-\u007e]*$/.test(value)) {
54
+ throw invalid(`metadata "${name}": printable ASCII only — encode anything else`);
55
+ }
56
+ bytes += name.length + value.length;
57
+ out[name] = value;
58
+ }
59
+ if (bytes > MAX_METADATA_BYTES)
60
+ throw invalid(`metadata is at most ${MAX_METADATA_BYTES} bytes`);
61
+ return out;
62
+ }
63
+ export function checkExpiresIn(seconds, fallback) {
64
+ const s = seconds ?? fallback;
65
+ if (!Number.isInteger(s) || s < 1 || s > MAX_EXPIRES_IN) {
66
+ throw invalid(`expiresIn is whole seconds, 1–${MAX_EXPIRES_IN}`);
67
+ }
68
+ return s;
69
+ }
70
+ export function checkSignUpload(o) {
71
+ if (!Number.isSafeInteger(o.size) || o.size < 0)
72
+ throw invalid('size is the exact byte length');
73
+ if (typeof o.contentType !== 'string' || o.contentType.length === 0) {
74
+ throw invalid('contentType is required: it is signed into the URL');
75
+ }
76
+ }
77
+ export const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
78
+ /** Bytes and a content type, from anything `put` accepts. */
79
+ export async function toBytes(body, contentType) {
80
+ if (typeof body === 'string') {
81
+ return { bytes: utf8.encode(body), contentType: contentType ?? 'text/plain; charset=utf-8' };
82
+ }
83
+ if (body instanceof Blob) {
84
+ return {
85
+ bytes: new Uint8Array(await body.arrayBuffer()),
86
+ contentType: contentType ?? (body.type || DEFAULT_CONTENT_TYPE),
87
+ };
88
+ }
89
+ if (body instanceof ArrayBuffer) {
90
+ return {
91
+ bytes: new Uint8Array(body.slice(0)),
92
+ contentType: contentType ?? DEFAULT_CONTENT_TYPE,
93
+ };
94
+ }
95
+ if (ArrayBuffer.isView(body)) {
96
+ const copy = new Uint8Array(body.byteLength);
97
+ copy.set(new Uint8Array(body.buffer, body.byteOffset, body.byteLength));
98
+ return { bytes: copy, contentType: contentType ?? DEFAULT_CONTENT_TYPE };
99
+ }
100
+ if (body instanceof ReadableStream) {
101
+ const bytes = new Uint8Array(await new Response(body).arrayBuffer());
102
+ return { bytes, contentType: contentType ?? DEFAULT_CONTENT_TYPE };
103
+ }
104
+ throw invalid('a body is a string, ArrayBuffer, typed array, Blob or ReadableStream');
105
+ }
106
+ /** `upload`'s arguments as a `put`: a generated key under the prefix, the file's extension kept. */
107
+ export function uploadAsPut(file, options, defaults) {
108
+ checkPrefix(options?.prefix);
109
+ const isBlob = file instanceof Blob;
110
+ const name = isBlob ? file.name : file.name;
111
+ const ext = typeof name === 'string' ? extensionOf(name) : '';
112
+ const prefix = options?.prefix ? `${options.prefix.replace(/\/+$/, '')}/` : '';
113
+ const key = `${prefix}${crypto.randomUUID()}${ext}`;
114
+ const contentType = isBlob ? file.type || undefined : file.contentType;
115
+ const cacheControl = options?.cacheControl ?? defaults.cacheControl;
116
+ return {
117
+ key,
118
+ body: isBlob ? file : file.body,
119
+ options: {
120
+ ...(contentType ? { contentType } : {}),
121
+ ...(cacheControl ? { cacheControl } : {}),
122
+ ...(options?.metadata ? { metadata: options.metadata } : {}),
123
+ },
124
+ };
125
+ }
126
+ /** `.jpg` from `Cover Art.JPG`; nothing when there is no sensible extension. */
127
+ export function extensionOf(name) {
128
+ const m = /\.([A-Za-z0-9]{1,10})$/.exec(name);
129
+ return m ? `.${m[1].toLowerCase()}` : '';
130
+ }
131
+ /** How the public store caches: generated keys never change, chosen keys might. */
132
+ export const IMMUTABLE = 'public, max-age=31536000, immutable';
133
+ export const SHORT_PUBLIC = 'public, max-age=300';
134
+ /**
135
+ * Public objects render in a browser tab only if they are media: raster
136
+ * images, audio, video. Everything else — HTML, SVG, PDF, text — is served as
137
+ * an attachment, because a static scam page on a KF-held domain needs no
138
+ * script to do harm (earlier KF projects drew takedown notices for exactly
139
+ * that). Only direct navigation is affected: <img>, <video>, fonts, CSS and
140
+ * fetch() ignore the header, so embeds keep working.
141
+ *
142
+ * The console's assets zone enforces the same rule on responses, for anyone
143
+ * writing around the SDK; this makes every backend store it, so local and
144
+ * staging behave as production does.
145
+ */
146
+ const INLINE_PUBLIC = /^(image\/(png|jpeg|gif|webp|avif)|audio\/[a-z0-9.+-]+|video\/[a-z0-9.+-]+)$/;
147
+ export function rendersInline(contentType) {
148
+ return INLINE_PUBLIC.test(contentType.split(';')[0].trim().toLowerCase());
149
+ }
150
+ /**
151
+ * The disposition a public object is stored with. A caller's own `attachment;
152
+ * filename=…` is kept; asking for `inline` on a type outside the list is not
153
+ * honoured.
154
+ */
155
+ export function publicDisposition(contentType, requested) {
156
+ if (rendersInline(contentType))
157
+ return requested;
158
+ return requested?.trim().toLowerCase().startsWith('attachment') ? requested : 'attachment';
159
+ }
160
+ /** An attachment disposition, safe for any file name (RFC 6266 + RFC 5987). */
161
+ export function attachment(filename) {
162
+ const ascii = filename.replace(/[^\u0020-\u007e]/g, '_').replace(/["\\]/g, '_');
163
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
164
+ }
165
+ function invalid(message) {
166
+ return new StorageError('invalid', message);
167
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * AWS Signature Version 4 on WebCrypto — the dialect R2 and RustFS speak.
3
+ *
4
+ * Hand-written rather than aws4fetch because a presigned upload must sign
5
+ * `content-type` and `content-length`: that is what makes `signUpload` exact
6
+ * on every backend (R2 has no POST policies, so no `content-length-range`),
7
+ * and aws4fetch refuses to sign either header. Pure: credentials, time and
8
+ * request are arguments, so the tests check it against AWS's published
9
+ * examples byte for byte.
10
+ */
11
+ export interface Credentials {
12
+ accessKeyId: string;
13
+ secretAccessKey: string;
14
+ region: string;
15
+ /**
16
+ * Temporary credentials' third part (R2 temporary credentials, STS). Signed
17
+ * as `x-amz-security-token` on a request and carried as
18
+ * `X-Amz-Security-Token` in a presigned URL.
19
+ */
20
+ sessionToken?: string;
21
+ }
22
+ export interface SignInput {
23
+ method: string;
24
+ /** Its path must already be encoded exactly as it will be sent (`encodeKey`). */
25
+ url: URL;
26
+ /** Headers to sign and send. `host` is signed from the URL and never sent (fetch sets it). */
27
+ headers?: Record<string, string>;
28
+ credentials: Credentials;
29
+ /** Defaults to now. */
30
+ date?: Date;
31
+ }
32
+ export declare const EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
33
+ export declare const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
34
+ /** RFC 3986 unreserved characters pass; everything else is %XX of its UTF-8 bytes. */
35
+ export declare function encodeRfc3986(s: string): string;
36
+ /** An object key as a URL path: each segment encoded, the slashes kept. */
37
+ export declare function encodeKey(key: string): string;
38
+ /** The headers to send, `authorization` included, for a request signed in the Authorization header. */
39
+ export declare function signRequest(o: SignInput & {
40
+ payloadHash: string;
41
+ }): Promise<Record<string, string>>;
42
+ /**
43
+ * A presigned URL: the signature travels in the query string, and whoever
44
+ * holds the URL must send exactly the signed headers with exactly these
45
+ * values, before `expiresIn` runs out.
46
+ */
47
+ export declare function presign(o: SignInput & {
48
+ expiresIn: number;
49
+ }): Promise<string>;
50
+ export declare function sha256Hex(data: Uint8Array | string): Promise<string>;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * AWS Signature Version 4 on WebCrypto — the dialect R2 and RustFS speak.
3
+ *
4
+ * Hand-written rather than aws4fetch because a presigned upload must sign
5
+ * `content-type` and `content-length`: that is what makes `signUpload` exact
6
+ * on every backend (R2 has no POST policies, so no `content-length-range`),
7
+ * and aws4fetch refuses to sign either header. Pure: credentials, time and
8
+ * request are arguments, so the tests check it against AWS's published
9
+ * examples byte for byte.
10
+ */
11
+ const utf8 = new TextEncoder();
12
+ const SERVICE = 's3';
13
+ export const EMPTY_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
14
+ export const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
15
+ /** RFC 3986 unreserved characters pass; everything else is %XX of its UTF-8 bytes. */
16
+ export function encodeRfc3986(s) {
17
+ return encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
18
+ }
19
+ /** An object key as a URL path: each segment encoded, the slashes kept. */
20
+ export function encodeKey(key) {
21
+ return key.split('/').map(encodeRfc3986).join('/');
22
+ }
23
+ /** The headers to send, `authorization` included, for a request signed in the Authorization header. */
24
+ export async function signRequest(o) {
25
+ const { amzDate, day } = stamps(o.date ?? new Date());
26
+ const headers = lowercase({
27
+ ...o.headers,
28
+ 'x-amz-date': amzDate,
29
+ 'x-amz-content-sha256': o.payloadHash,
30
+ ...(o.credentials.sessionToken ? { 'x-amz-security-token': o.credentials.sessionToken } : {}),
31
+ });
32
+ const { canonical, signed } = canonicalHeaders(o.url, headers);
33
+ const request = [
34
+ o.method,
35
+ o.url.pathname,
36
+ canonicalQuery(o.url),
37
+ canonical,
38
+ signed,
39
+ o.payloadHash,
40
+ ].join('\n');
41
+ const signature = await sign(o.credentials, day, amzDate, request);
42
+ return {
43
+ ...headers,
44
+ authorization: `AWS4-HMAC-SHA256 Credential=${o.credentials.accessKeyId}/${scope(o.credentials, day)}, SignedHeaders=${signed}, Signature=${signature}`,
45
+ };
46
+ }
47
+ /**
48
+ * A presigned URL: the signature travels in the query string, and whoever
49
+ * holds the URL must send exactly the signed headers with exactly these
50
+ * values, before `expiresIn` runs out.
51
+ */
52
+ export async function presign(o) {
53
+ const { amzDate, day } = stamps(o.date ?? new Date());
54
+ const headers = lowercase(o.headers ?? {});
55
+ const { canonical, signed } = canonicalHeaders(o.url, headers);
56
+ const url = new URL(o.url);
57
+ url.searchParams.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
58
+ if (o.credentials.sessionToken) {
59
+ url.searchParams.set('X-Amz-Security-Token', o.credentials.sessionToken);
60
+ }
61
+ url.searchParams.set('X-Amz-Credential', `${o.credentials.accessKeyId}/${scope(o.credentials, day)}`);
62
+ url.searchParams.set('X-Amz-Date', amzDate);
63
+ url.searchParams.set('X-Amz-Expires', String(o.expiresIn));
64
+ url.searchParams.set('X-Amz-SignedHeaders', signed);
65
+ const request = [
66
+ o.method,
67
+ url.pathname,
68
+ canonicalQuery(url),
69
+ canonical,
70
+ signed,
71
+ UNSIGNED_PAYLOAD,
72
+ ].join('\n');
73
+ const signature = await sign(o.credentials, day, amzDate, request);
74
+ // Appended by hand: URLSearchParams would re-encode the query we just signed.
75
+ return `${url.origin}${url.pathname}?${canonicalQuery(url)}&X-Amz-Signature=${signature}`;
76
+ }
77
+ export async function sha256Hex(data) {
78
+ const bytes = typeof data === 'string' ? utf8.encode(data) : data;
79
+ return hex(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)));
80
+ }
81
+ /* ------------------------------ internals ------------------------------ */
82
+ function stamps(date) {
83
+ const amzDate = date
84
+ .toISOString()
85
+ .replace(/[-:]/g, '')
86
+ .replace(/\.\d{3}/, '');
87
+ return { amzDate, day: amzDate.slice(0, 8) };
88
+ }
89
+ function scope(c, day) {
90
+ return `${day}/${c.region}/${SERVICE}/aws4_request`;
91
+ }
92
+ function lowercase(h) {
93
+ const out = {};
94
+ for (const [k, v] of Object.entries(h))
95
+ out[k.toLowerCase()] = v;
96
+ return out;
97
+ }
98
+ function canonicalHeaders(url, headers) {
99
+ const all = { ...headers, host: url.host };
100
+ const names = Object.keys(all).sort();
101
+ const canonical = names.map((n) => `${n}:${all[n].trim().replace(/\s+/g, ' ')}\n`).join('');
102
+ return { canonical, signed: names.join(';') };
103
+ }
104
+ /** Every parameter, RFC 3986-encoded, sorted by encoded name then value. */
105
+ function canonicalQuery(url) {
106
+ return [...url.searchParams]
107
+ .map(([k, v]) => [encodeRfc3986(k), encodeRfc3986(v)])
108
+ .sort(([ak, av], [bk, bv]) => (ak < bk ? -1 : ak > bk ? 1 : av < bv ? -1 : av > bv ? 1 : 0))
109
+ .map(([k, v]) => `${k}=${v}`)
110
+ .join('&');
111
+ }
112
+ async function sign(c, day, amzDate, request) {
113
+ const toSign = ['AWS4-HMAC-SHA256', amzDate, scope(c, day), await sha256Hex(request)].join('\n');
114
+ let key = utf8.encode(`AWS4${c.secretAccessKey}`);
115
+ for (const part of [day, c.region, SERVICE, 'aws4_request'])
116
+ key = await hmac(key, part);
117
+ return hex(await hmac(key, toSign));
118
+ }
119
+ async function hmac(key, data) {
120
+ const k = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
121
+ return new Uint8Array(await crypto.subtle.sign('HMAC', k, utf8.encode(data)));
122
+ }
123
+ function hex(bytes) {
124
+ let s = '';
125
+ for (const b of bytes)
126
+ s += b.toString(16).padStart(2, '0');
127
+ return s;
128
+ }
@@ -0,0 +1,25 @@
1
+ import type { Storage } from './types.js';
2
+ /**
3
+ * The storage interface with no network: for a project's unit tests, and
4
+ * the reference the conformance suite is written against first. It enforces
5
+ * everything the real backends do — the same key rules (shared.ts), signed
6
+ * URLs that expire and check their signed headers, a public store that
7
+ * serves by URL — so a test that passes here is not passing by accident.
8
+ *
9
+ * Signed and public URLs are served by `fetch` on the returned object:
10
+ *
11
+ * const storage = memoryStorage()
12
+ * const up = await storage.signUpload('a.txt', { contentType: 'text/plain', size: 2 })
13
+ * await storage.fetch(up.url, { method: 'PUT', headers: up.headers, body: 'hi' })
14
+ */
15
+ export interface MemoryStorageOptions {
16
+ /** The public store: its base URL, or false for an environment that doesn't declare `public`. Default on. */
17
+ public?: false | {
18
+ base?: string;
19
+ };
20
+ }
21
+ export interface MemoryStorage extends Storage {
22
+ /** Serves the URLs this store hands out: signed uploads, signed downloads, public objects. */
23
+ fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
24
+ }
25
+ export declare function memoryStorage(options?: MemoryStorageOptions): MemoryStorage;
@@ -0,0 +1,242 @@
1
+ import { StorageError } from './errors.js';
2
+ import { attachment, checkExpiresIn, DOWNLOAD_EXPIRES_IN, UPLOAD_EXPIRES_IN, checkKey, checkMetadata, checkPrefix, checkSignUpload, IMMUTABLE, publicDisposition, SHORT_PUBLIC, toBytes, uploadAsPut, } from './shared.js';
3
+ import { encodeKey } from './sigv4.js';
4
+ const PRIVATE_ORIGIN = 'https://signed.storage.test';
5
+ const DEFAULT_PUBLIC_BASE = 'https://public.storage.test';
6
+ export function memoryStorage(options = {}) {
7
+ const grants = new Map();
8
+ const priv = new MemoryStore(grants, false);
9
+ const pub = options.public === false
10
+ ? null
11
+ : new MemoryStore(grants, true, (options.public?.base ?? DEFAULT_PUBLIC_BASE).replace(/\/+$/, ''));
12
+ async function serve(input, init) {
13
+ const req = new Request(input, init);
14
+ const url = new URL(req.url);
15
+ if (pub &&
16
+ req.url.startsWith(`${pub.base}/`) &&
17
+ (req.method === 'GET' || req.method === 'HEAD')) {
18
+ const key = decodeKey(url.pathname);
19
+ const e = pub.objects.get(key);
20
+ if (!e)
21
+ return new Response('not found', { status: 404 });
22
+ return objectResponse(e, req.method === 'HEAD');
23
+ }
24
+ if (url.origin !== PRIVATE_ORIGIN)
25
+ return new Response('not a URL this store handed out', { status: 404 });
26
+ const grant = grants.get(url.searchParams.get('kf-signature') ?? '');
27
+ if (!grant || grant.method !== req.method || grant.key !== decodeKey(url.pathname)) {
28
+ return new Response('SignatureDoesNotMatch', { status: 403 });
29
+ }
30
+ if (Date.now() > grant.expires)
31
+ return new Response('Request has expired', { status: 403 });
32
+ if (grant.method === 'GET') {
33
+ const e = grant.store.objects.get(grant.key);
34
+ if (!e)
35
+ return new Response('not found', { status: 404 });
36
+ const res = objectResponse(e, false);
37
+ if (grant.disposition)
38
+ res.headers.set('content-disposition', grant.disposition);
39
+ return res;
40
+ }
41
+ // PUT: exactly the signed headers, exactly the signed size.
42
+ const bytes = new Uint8Array(await req.arrayBuffer());
43
+ if (req.headers.get('content-type') !== grant.contentType ||
44
+ (req.headers.get('cache-control') ?? undefined) !== grant.cacheControl ||
45
+ (req.headers.get('content-disposition') ?? undefined) !== grant.disposition ||
46
+ bytes.byteLength !== grant.size) {
47
+ return new Response('SignatureDoesNotMatch', { status: 403 });
48
+ }
49
+ await grant.store.put(grant.key, bytes, {
50
+ contentType: grant.contentType,
51
+ ...(grant.cacheControl ? { cacheControl: grant.cacheControl } : {}),
52
+ ...(grant.disposition ? { contentDisposition: grant.disposition } : {}),
53
+ });
54
+ return new Response(null, { status: 200 });
55
+ }
56
+ return {
57
+ put: (key, body, o) => priv.put(key, body, o),
58
+ get: (key) => priv.get(key),
59
+ head: (key) => priv.head(key),
60
+ delete: (key) => priv.delete(key),
61
+ list: (o) => priv.list(o),
62
+ signUpload: (key, o) => priv.signUpload(key, o),
63
+ signDownload: (key, o) => priv.signDownload(key, o),
64
+ upload: (file, o) => priv.upload(file, o),
65
+ get public() {
66
+ if (!pub) {
67
+ throw new StorageError('not_configured', 'this store has no public store (memoryStorage({ public: false }))');
68
+ }
69
+ return pub;
70
+ },
71
+ fetch: serve,
72
+ };
73
+ }
74
+ class MemoryStore {
75
+ grants;
76
+ isPublic;
77
+ base;
78
+ objects = new Map();
79
+ constructor(grants, isPublic, base) {
80
+ this.grants = grants;
81
+ this.isPublic = isPublic;
82
+ this.base = base;
83
+ }
84
+ async put(key, body, opts = {}) {
85
+ checkKey(key);
86
+ const metadata = checkMetadata(opts.metadata);
87
+ const { bytes, contentType } = await toBytes(body, opts.contentType);
88
+ const cacheControl = opts.cacheControl ?? (this.isPublic ? SHORT_PUBLIC : undefined);
89
+ const disposition = this.isPublic
90
+ ? publicDisposition(contentType, opts.contentDisposition)
91
+ : opts.contentDisposition;
92
+ const e = {
93
+ key,
94
+ size: bytes.byteLength,
95
+ etag: crypto.randomUUID().replaceAll('-', ''),
96
+ uploaded: new Date(Math.floor(Date.now() / 1000) * 1000),
97
+ contentType,
98
+ metadata,
99
+ bytes,
100
+ };
101
+ if (cacheControl)
102
+ e.cacheControl = cacheControl;
103
+ if (disposition)
104
+ e.contentDisposition = disposition;
105
+ this.objects.set(key, e);
106
+ return describe(e);
107
+ }
108
+ async get(key) {
109
+ checkKey(key);
110
+ const e = this.objects.get(key);
111
+ if (!e)
112
+ return null;
113
+ const copy = e.bytes.slice();
114
+ return {
115
+ ...describe(e),
116
+ body: new Response(copy).body,
117
+ arrayBuffer: async () => copy.buffer.slice(0),
118
+ text: async () => new TextDecoder().decode(copy),
119
+ };
120
+ }
121
+ async head(key) {
122
+ checkKey(key);
123
+ const e = this.objects.get(key);
124
+ return e ? describe(e) : null;
125
+ }
126
+ async delete(key) {
127
+ checkKey(key);
128
+ this.objects.delete(key);
129
+ }
130
+ async list(opts = {}) {
131
+ checkPrefix(opts.prefix);
132
+ const limit = opts.limit ?? 1000;
133
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
134
+ throw new StorageError('invalid', 'limit is 1–1000');
135
+ }
136
+ const after = opts.cursor ? decodeCursor(opts.cursor) : undefined;
137
+ const keys = [...this.objects.keys()]
138
+ .filter((k) => k.startsWith(opts.prefix ?? '') && (after === undefined || byteCompare(k, after) > 0))
139
+ .sort(byteCompare);
140
+ const page = keys.slice(0, limit);
141
+ const objects = page.map((k) => {
142
+ const e = this.objects.get(k);
143
+ return { key: e.key, size: e.size, etag: e.etag, uploaded: e.uploaded };
144
+ });
145
+ return keys.length > limit
146
+ ? { objects, cursor: btoa(encodeURIComponent(page.at(-1))) }
147
+ : { objects };
148
+ }
149
+ async signUpload(key, o) {
150
+ checkKey(key);
151
+ checkSignUpload(o);
152
+ const expiresIn = checkExpiresIn(o.expiresIn, UPLOAD_EXPIRES_IN);
153
+ const cacheControl = o.cacheControl ?? (this.isPublic ? SHORT_PUBLIC : undefined);
154
+ const headers = { 'content-type': o.contentType };
155
+ if (cacheControl)
156
+ headers['cache-control'] = cacheControl;
157
+ const disposition = this.isPublic ? publicDisposition(o.contentType, undefined) : undefined;
158
+ if (disposition)
159
+ headers['content-disposition'] = disposition;
160
+ const url = this.grant({
161
+ method: 'PUT',
162
+ key,
163
+ expiresIn,
164
+ contentType: o.contentType,
165
+ size: o.size,
166
+ ...(cacheControl ? { cacheControl } : {}),
167
+ ...(disposition ? { disposition } : {}),
168
+ });
169
+ return {
170
+ key,
171
+ url,
172
+ method: 'PUT',
173
+ headers,
174
+ expiresAt: new Date(Date.now() + expiresIn * 1000),
175
+ };
176
+ }
177
+ async signDownload(key, o = {}) {
178
+ checkKey(key);
179
+ const expiresIn = checkExpiresIn(o.expiresIn, DOWNLOAD_EXPIRES_IN);
180
+ return this.grant({
181
+ method: 'GET',
182
+ key,
183
+ expiresIn,
184
+ ...(o.filename ? { disposition: attachment(o.filename) } : {}),
185
+ });
186
+ }
187
+ async upload(file, options) {
188
+ const p = uploadAsPut(file, options, this.isPublic ? { cacheControl: IMMUTABLE } : {});
189
+ return this.put(p.key, p.body, p.options);
190
+ }
191
+ url(key) {
192
+ checkKey(key);
193
+ if (!this.base)
194
+ throw new StorageError('not_configured', 'the private store has no public URLs');
195
+ return `${this.base}/${encodeKey(key)}`;
196
+ }
197
+ grant(g) {
198
+ const token = crypto.randomUUID();
199
+ const { expiresIn, ...rest } = g;
200
+ this.grants.set(token, { ...rest, store: this, expires: Date.now() + expiresIn * 1000 });
201
+ return `${PRIVATE_ORIGIN}/${encodeKey(g.key)}?kf-signature=${token}`;
202
+ }
203
+ }
204
+ function describe(e) {
205
+ const { bytes: _bytes, ...meta } = e;
206
+ return { ...meta, metadata: { ...meta.metadata } };
207
+ }
208
+ function objectResponse(e, headOnly) {
209
+ const headers = new Headers({
210
+ 'content-type': e.contentType,
211
+ 'content-length': String(e.size),
212
+ etag: `"${e.etag}"`,
213
+ 'last-modified': e.uploaded.toUTCString(),
214
+ });
215
+ if (e.cacheControl)
216
+ headers.set('cache-control', e.cacheControl);
217
+ if (e.contentDisposition)
218
+ headers.set('content-disposition', e.contentDisposition);
219
+ return new Response(headOnly ? null : e.bytes.slice(), { status: 200, headers });
220
+ }
221
+ function decodeKey(pathname) {
222
+ return pathname.slice(1).split('/').map(decodeURIComponent).join('/');
223
+ }
224
+ function decodeCursor(cursor) {
225
+ try {
226
+ return decodeURIComponent(atob(cursor));
227
+ }
228
+ catch {
229
+ throw new StorageError('invalid', 'not a cursor this store handed out');
230
+ }
231
+ }
232
+ const utf8 = new TextEncoder();
233
+ /** S3 lists in UTF-8 byte order, which differs from UTF-16 `<` above the BMP. */
234
+ function byteCompare(a, b) {
235
+ const x = utf8.encode(a);
236
+ const y = utf8.encode(b);
237
+ for (let i = 0; i < Math.min(x.length, y.length); i++) {
238
+ if (x[i] !== y[i])
239
+ return x[i] - y[i];
240
+ }
241
+ return x.length - y.length;
242
+ }