@moloodev/editor-store 0.0.0-stage → 0.2.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/package.json +32 -3
- package/src/blob.ts +19 -0
- package/src/index.ts +15 -0
- package/src/pointer.ts +105 -0
- package/src/private.ts +102 -0
- package/src/read.ts +54 -0
- package/src/upload.ts +196 -0
- package/src/write.ts +43 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moloodev/editor-store",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts",
|
|
7
|
+
"./write": "./src/write.ts",
|
|
8
|
+
"./blob": "./src/blob.ts",
|
|
9
|
+
"./private": "./src/private.ts",
|
|
10
|
+
"./upload": "./src/upload.ts"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@vercel/blob": "^2.8.0",
|
|
14
|
+
"zod": "^4.5.4",
|
|
15
|
+
"@moloodev/platform": "0.2.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^24.13.3",
|
|
19
|
+
"tsx": "^4.23.13",
|
|
20
|
+
"typescript": "^7.0.2",
|
|
21
|
+
"@moloodev/typescript-config": "0.2.0"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"!src/**/*.test.ts",
|
|
29
|
+
"!src/**/*.test.tsx"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test": "tsx --test 'src/**/*.test.ts'"
|
|
34
|
+
}
|
|
6
35
|
}
|
package/src/blob.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { put } from "@vercel/blob";
|
|
2
|
+
import type { ObjectWriter } from "./pointer";
|
|
3
|
+
|
|
4
|
+
// Explicit token so writes never use the platform media store.
|
|
5
|
+
export function blobWriter(token: string): ObjectWriter {
|
|
6
|
+
if (token.length === 0) throw new Error("public store token is empty");
|
|
7
|
+
return {
|
|
8
|
+
async write(pathname, body, meta) {
|
|
9
|
+
await put(pathname, Buffer.from(body), {
|
|
10
|
+
access: "public",
|
|
11
|
+
token,
|
|
12
|
+
addRandomSuffix: false,
|
|
13
|
+
allowOverwrite: true,
|
|
14
|
+
contentType: meta.contentType,
|
|
15
|
+
cacheControlMaxAge: meta.maxAge,
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ARTIFACT_MAX_AGE,
|
|
3
|
+
cacheControl,
|
|
4
|
+
encodePointer,
|
|
5
|
+
isDnsId,
|
|
6
|
+
parsePointer,
|
|
7
|
+
pointerFor,
|
|
8
|
+
pointerPath,
|
|
9
|
+
SCRIPT_TYPE,
|
|
10
|
+
scriptPath,
|
|
11
|
+
type ObjectReader,
|
|
12
|
+
type ObjectWriter,
|
|
13
|
+
type WriteMeta,
|
|
14
|
+
} from "./pointer";
|
|
15
|
+
export { publicReader } from "./read";
|
package/src/pointer.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { parseNetwork } from "@moloodev/platform/network";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
const DNS_ID = /^[0-9a-z]{24}$/;
|
|
5
|
+
const SCRIPT_PATH = /^revisions\/[0-9a-z]{24}\/[0-9a-z]{24}\/editor\.js$/;
|
|
6
|
+
const POINTER_BYTES_MAX = 4096;
|
|
7
|
+
|
|
8
|
+
export const ARTIFACT_MAX_AGE = 31_536_000;
|
|
9
|
+
export const POINTER_MAX_AGE = 60;
|
|
10
|
+
export const SCRIPT_TYPE = "text/javascript; charset=utf-8";
|
|
11
|
+
export const POINTER_TYPE = "application/json; charset=utf-8";
|
|
12
|
+
|
|
13
|
+
const idSchema = z.string().regex(DNS_ID);
|
|
14
|
+
const pointerSchema = z.strictObject({
|
|
15
|
+
revisionId: idSchema,
|
|
16
|
+
network: z.array(z.string()),
|
|
17
|
+
paths: z.strictObject({
|
|
18
|
+
script: z.string().regex(SCRIPT_PATH),
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type Pointer = z.infer<typeof pointerSchema>;
|
|
23
|
+
|
|
24
|
+
type ObjectBody = {
|
|
25
|
+
readonly body: Uint8Array;
|
|
26
|
+
readonly contentType: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ObjectReader = {
|
|
30
|
+
read(pathname: string): Promise<ObjectBody | null>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type WriteMeta = {
|
|
34
|
+
readonly contentType: string;
|
|
35
|
+
readonly maxAge: number;
|
|
36
|
+
readonly immutable: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type ObjectWriter = {
|
|
40
|
+
write(pathname: string, body: Uint8Array, meta: WriteMeta): Promise<void>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function isDnsId(value: string): boolean {
|
|
44
|
+
return DNS_ID.test(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isRevisionScript(pathname: string): boolean {
|
|
48
|
+
return SCRIPT_PATH.test(pathname);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function cacheControl(meta: Pick<WriteMeta, "maxAge" | "immutable">): string {
|
|
52
|
+
const base = `public, max-age=${String(meta.maxAge)}`;
|
|
53
|
+
return meta.immutable ? `${base}, immutable` : base;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function scriptPath(editorId: string, revisionId: string): string {
|
|
57
|
+
if (!isDnsId(editorId) || !isDnsId(revisionId)) throw new Error("invalid id");
|
|
58
|
+
return `revisions/${editorId}/${revisionId}/editor.js`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function pointerFor(
|
|
62
|
+
editorId: string,
|
|
63
|
+
revisionId: string,
|
|
64
|
+
network: readonly string[],
|
|
65
|
+
): Pointer {
|
|
66
|
+
return {
|
|
67
|
+
revisionId,
|
|
68
|
+
network: [...network],
|
|
69
|
+
paths: { script: scriptPath(editorId, revisionId) },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function pointerPath(editorId: string): string {
|
|
74
|
+
if (!isDnsId(editorId)) throw new Error("invalid id");
|
|
75
|
+
return `pointers/${editorId}.json`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function encodePointer(pointer: Pointer): Uint8Array {
|
|
79
|
+
return new TextEncoder().encode(JSON.stringify(pointer));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parsePointer(editorId: string, bytes: Uint8Array): Pointer | undefined {
|
|
83
|
+
if (!isDnsId(editorId) || bytes.byteLength > POINTER_BYTES_MAX) return undefined;
|
|
84
|
+
let raw: unknown;
|
|
85
|
+
try {
|
|
86
|
+
raw = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const parsed = pointerSchema.safeParse(raw);
|
|
91
|
+
if (!parsed.success) return undefined;
|
|
92
|
+
let network: string[];
|
|
93
|
+
try {
|
|
94
|
+
network = parseNetwork(parsed.data.network);
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const expected = scriptPath(editorId, parsed.data.revisionId);
|
|
99
|
+
if (parsed.data.paths.script !== expected) return undefined;
|
|
100
|
+
return {
|
|
101
|
+
revisionId: parsed.data.revisionId,
|
|
102
|
+
network,
|
|
103
|
+
paths: { script: expected },
|
|
104
|
+
};
|
|
105
|
+
}
|
package/src/private.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { BlobNotFoundError, del, get, put } from "@vercel/blob";
|
|
2
|
+
import { isRevisionScript, type ObjectReader, type ObjectWriter } from "./pointer";
|
|
3
|
+
|
|
4
|
+
type PrivatePut = (
|
|
5
|
+
pathname: string,
|
|
6
|
+
body: Buffer,
|
|
7
|
+
options: {
|
|
8
|
+
access: "private";
|
|
9
|
+
token: string;
|
|
10
|
+
addRandomSuffix: false;
|
|
11
|
+
allowOverwrite: true;
|
|
12
|
+
contentType: string;
|
|
13
|
+
cacheControlMaxAge: number;
|
|
14
|
+
},
|
|
15
|
+
) => Promise<unknown>;
|
|
16
|
+
|
|
17
|
+
// Separate token and access so a submission cannot land in the public store.
|
|
18
|
+
export function privateWriter(token: string, putBlob?: PrivatePut): ObjectWriter {
|
|
19
|
+
if (token.length === 0) throw new Error("private store token is empty");
|
|
20
|
+
const writeBlob: PrivatePut =
|
|
21
|
+
putBlob ??
|
|
22
|
+
((pathname, body, options) => put(pathname, body, options));
|
|
23
|
+
return {
|
|
24
|
+
async write(pathname, body, meta) {
|
|
25
|
+
await writeBlob(pathname, Buffer.from(body), {
|
|
26
|
+
access: "private",
|
|
27
|
+
token,
|
|
28
|
+
addRandomSuffix: false,
|
|
29
|
+
allowOverwrite: true,
|
|
30
|
+
contentType: meta.contentType,
|
|
31
|
+
cacheControlMaxAge: meta.maxAge,
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type PrivateGetResult = {
|
|
38
|
+
statusCode: number;
|
|
39
|
+
stream: ReadableStream<Uint8Array> | null;
|
|
40
|
+
blob: { contentType: string | null };
|
|
41
|
+
} | null;
|
|
42
|
+
|
|
43
|
+
type PrivateGet = (
|
|
44
|
+
pathname: string,
|
|
45
|
+
options: { access: "private"; token: string; useCache: false },
|
|
46
|
+
) => Promise<PrivateGetResult>;
|
|
47
|
+
|
|
48
|
+
async function defaultGet(
|
|
49
|
+
pathname: string,
|
|
50
|
+
options: { access: "private"; token: string; useCache: false },
|
|
51
|
+
): Promise<PrivateGetResult> {
|
|
52
|
+
return get(pathname, options);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function privateReader(token: string, getBlob: PrivateGet = defaultGet): ObjectReader {
|
|
56
|
+
if (token.length === 0) throw new Error("private store token is empty");
|
|
57
|
+
return {
|
|
58
|
+
async read(pathname) {
|
|
59
|
+
if (!isRevisionScript(pathname)) return null;
|
|
60
|
+
let result: PrivateGetResult;
|
|
61
|
+
try {
|
|
62
|
+
result = await getBlob(pathname, {
|
|
63
|
+
access: "private",
|
|
64
|
+
token,
|
|
65
|
+
useCache: false,
|
|
66
|
+
});
|
|
67
|
+
} catch {
|
|
68
|
+
// The blob client puts the store URL on the error. Callers see a fixed message.
|
|
69
|
+
throw new Error("private store read failed");
|
|
70
|
+
}
|
|
71
|
+
if (!result || result.statusCode !== 200 || result.stream === null) return null;
|
|
72
|
+
return {
|
|
73
|
+
body: new Uint8Array(await new Response(result.stream).arrayBuffer()),
|
|
74
|
+
contentType: result.blob.contentType ?? "application/octet-stream",
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type PrivateDeleter = {
|
|
81
|
+
delete(pathname: string): Promise<void>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type PrivateDel = (pathname: string, options: { token: string }) => Promise<void>;
|
|
85
|
+
|
|
86
|
+
// Private token only. A purge must not be able to remove a public pointer.
|
|
87
|
+
export function privateDeleter(token: string, delBlob?: PrivateDel): PrivateDeleter {
|
|
88
|
+
if (token.length === 0) throw new Error("private store token is empty");
|
|
89
|
+
const remove: PrivateDel = delBlob ?? ((pathname, options) => del(pathname, options));
|
|
90
|
+
return {
|
|
91
|
+
async delete(pathname) {
|
|
92
|
+
if (!isRevisionScript(pathname)) throw new Error("private store path refused");
|
|
93
|
+
try {
|
|
94
|
+
await remove(pathname, { token });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (err instanceof BlobNotFoundError) return;
|
|
97
|
+
// The blob client puts the store URL on the error. Callers see a fixed message.
|
|
98
|
+
throw new Error("private store delete failed");
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/src/read.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ObjectReader } from "./pointer";
|
|
2
|
+
|
|
3
|
+
const OBJECT_PATH = /^pointers\/[0-9a-z]{24}\.json$|^revisions\/[0-9a-z]{24}\/[0-9a-z]{24}\/editor\.js$/;
|
|
4
|
+
|
|
5
|
+
export function publicReader(baseUrl: string, fetchImpl: typeof fetch = fetch): ObjectReader {
|
|
6
|
+
const base = parseBase(baseUrl);
|
|
7
|
+
return {
|
|
8
|
+
async read(pathname) {
|
|
9
|
+
const url = objectUrl(base, pathname);
|
|
10
|
+
if (url === null) return null;
|
|
11
|
+
const response = await fetchImpl(url);
|
|
12
|
+
if (response.status === 404) {
|
|
13
|
+
await response.body?.cancel();
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
if (!response.ok) {
|
|
17
|
+
await response.body?.cancel();
|
|
18
|
+
throw new Error("public store read failed");
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
body: new Uint8Array(await response.arrayBuffer()),
|
|
22
|
+
contentType: response.headers.get("content-type") ?? "application/octet-stream",
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseBase(baseUrl: string): URL {
|
|
29
|
+
let url: URL;
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(baseUrl);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error("public store base url is invalid");
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
url.protocol !== "https:" ||
|
|
37
|
+
url.username !== "" ||
|
|
38
|
+
url.password !== "" ||
|
|
39
|
+
url.search !== "" ||
|
|
40
|
+
url.hash !== "" ||
|
|
41
|
+
(url.pathname !== "/" && url.pathname !== "")
|
|
42
|
+
) {
|
|
43
|
+
throw new Error("public store base url is invalid");
|
|
44
|
+
}
|
|
45
|
+
url.pathname = "/";
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function objectUrl(base: URL, pathname: string): URL | null {
|
|
50
|
+
if (!OBJECT_PATH.test(pathname)) return null;
|
|
51
|
+
const url = new URL(pathname, base);
|
|
52
|
+
if (url.origin !== base.origin || url.pathname !== `/${pathname}`) return null;
|
|
53
|
+
return url;
|
|
54
|
+
}
|
package/src/upload.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { BlobNotFoundError, del, get } from "@vercel/blob";
|
|
3
|
+
import {
|
|
4
|
+
generateClientTokenFromReadWriteToken,
|
|
5
|
+
getPayloadFromClientToken,
|
|
6
|
+
put,
|
|
7
|
+
} from "@vercel/blob/client";
|
|
8
|
+
import { isDnsId, type ObjectReader } from "./pointer";
|
|
9
|
+
|
|
10
|
+
const UPLOAD_PATH = /^uploads\/[0-9a-z]{24}\/editor\.js$/;
|
|
11
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
12
|
+
|
|
13
|
+
export const SCRIPT_UPLOAD_TYPE = "text/javascript";
|
|
14
|
+
export const SCRIPT_UPLOAD_TTL_MS = 10 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
type PrivateGetResult = {
|
|
17
|
+
statusCode: number;
|
|
18
|
+
stream: ReadableStream<Uint8Array> | null;
|
|
19
|
+
blob: { contentType: string | null };
|
|
20
|
+
} | null;
|
|
21
|
+
|
|
22
|
+
type PrivateGet = (
|
|
23
|
+
pathname: string,
|
|
24
|
+
options: { access: "private"; token: string; useCache: false },
|
|
25
|
+
) => Promise<PrivateGetResult>;
|
|
26
|
+
|
|
27
|
+
type PrivateDel = (pathname: string, options: { token: string }) => Promise<void>;
|
|
28
|
+
|
|
29
|
+
type ClientPut = (
|
|
30
|
+
pathname: string,
|
|
31
|
+
body: Buffer,
|
|
32
|
+
options: {
|
|
33
|
+
access: "private";
|
|
34
|
+
token: string;
|
|
35
|
+
contentType: typeof SCRIPT_UPLOAD_TYPE;
|
|
36
|
+
},
|
|
37
|
+
) => Promise<unknown>;
|
|
38
|
+
|
|
39
|
+
type ScriptUploadFailure = "path" | "missing" | "size" | "digest" | "encoding";
|
|
40
|
+
|
|
41
|
+
type LoadedScript =
|
|
42
|
+
| { ok: true; script: string }
|
|
43
|
+
| { ok: false; reason: ScriptUploadFailure };
|
|
44
|
+
|
|
45
|
+
type ScriptUploadToken = {
|
|
46
|
+
pathname: string;
|
|
47
|
+
maximumBytes: number | undefined;
|
|
48
|
+
contentTypes: string[] | undefined;
|
|
49
|
+
addRandomSuffix: boolean | undefined;
|
|
50
|
+
allowOverwrite: boolean | undefined;
|
|
51
|
+
validUntil: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Shared private intake. Builtin publish and an external submission both
|
|
55
|
+
// check the bytes at this pathname before they write a revision.
|
|
56
|
+
export function uploadPath(id: string): string {
|
|
57
|
+
if (!isDnsId(id)) throw new Error("invalid id");
|
|
58
|
+
return `uploads/${id}/editor.js`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isUploadPath(pathname: string): boolean {
|
|
62
|
+
return UPLOAD_PATH.test(pathname);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function issueScriptUpload(input: {
|
|
66
|
+
storeToken: string;
|
|
67
|
+
pathname: string;
|
|
68
|
+
maximumBytes: number;
|
|
69
|
+
now?: number;
|
|
70
|
+
}): Promise<string> {
|
|
71
|
+
if (input.storeToken.length === 0) throw new Error("private store token is empty");
|
|
72
|
+
if (!isUploadPath(input.pathname)) throw new Error("upload path refused");
|
|
73
|
+
const now = input.now ?? Date.now();
|
|
74
|
+
return generateClientTokenFromReadWriteToken({
|
|
75
|
+
token: input.storeToken,
|
|
76
|
+
pathname: input.pathname,
|
|
77
|
+
maximumSizeInBytes: input.maximumBytes,
|
|
78
|
+
allowedContentTypes: [SCRIPT_UPLOAD_TYPE],
|
|
79
|
+
addRandomSuffix: false,
|
|
80
|
+
allowOverwrite: false,
|
|
81
|
+
validUntil: now + SCRIPT_UPLOAD_TTL_MS,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function readScriptUploadToken(clientToken: string): ScriptUploadToken {
|
|
86
|
+
const payload = getPayloadFromClientToken(clientToken);
|
|
87
|
+
const contentTypes = payload.allowedContentTypes;
|
|
88
|
+
return {
|
|
89
|
+
pathname: payload.pathname,
|
|
90
|
+
maximumBytes: payload.maximumSizeInBytes,
|
|
91
|
+
contentTypes: contentTypes === undefined ? undefined : [...contentTypes],
|
|
92
|
+
addRandomSuffix: payload.addRandomSuffix,
|
|
93
|
+
allowOverwrite: payload.allowOverwrite,
|
|
94
|
+
validUntil: payload.validUntil,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function putScriptUpload(
|
|
99
|
+
clientToken: string,
|
|
100
|
+
pathname: string,
|
|
101
|
+
body: Uint8Array,
|
|
102
|
+
putBlob?: ClientPut,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
if (!clientToken.startsWith("vercel_blob_client_")) throw new Error("upload token refused");
|
|
105
|
+
if (!isUploadPath(pathname)) throw new Error("upload path refused");
|
|
106
|
+
const write = putBlob ?? defaultPut;
|
|
107
|
+
await write(pathname, Buffer.from(body), {
|
|
108
|
+
access: "private",
|
|
109
|
+
token: clientToken,
|
|
110
|
+
contentType: SCRIPT_UPLOAD_TYPE,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function uploadReader(token: string, getBlob: PrivateGet = defaultGet): ObjectReader {
|
|
115
|
+
if (token.length === 0) throw new Error("private store token is empty");
|
|
116
|
+
return {
|
|
117
|
+
async read(pathname) {
|
|
118
|
+
if (!isUploadPath(pathname)) return null;
|
|
119
|
+
let result: PrivateGetResult;
|
|
120
|
+
try {
|
|
121
|
+
result = await getBlob(pathname, {
|
|
122
|
+
access: "private",
|
|
123
|
+
token,
|
|
124
|
+
useCache: false,
|
|
125
|
+
});
|
|
126
|
+
} catch {
|
|
127
|
+
throw new Error("private store read failed");
|
|
128
|
+
}
|
|
129
|
+
if (!result || result.statusCode !== 200 || result.stream === null) return null;
|
|
130
|
+
return {
|
|
131
|
+
body: new Uint8Array(await new Response(result.stream).arrayBuffer()),
|
|
132
|
+
contentType: result.blob.contentType ?? "application/octet-stream",
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function loadScriptUpload(input: {
|
|
139
|
+
reader: ObjectReader;
|
|
140
|
+
pathname: string;
|
|
141
|
+
sha256: string;
|
|
142
|
+
maximumBytes: number;
|
|
143
|
+
}): Promise<LoadedScript> {
|
|
144
|
+
if (!isUploadPath(input.pathname)) return { ok: false, reason: "path" };
|
|
145
|
+
if (!SHA256_HEX.test(input.sha256)) return { ok: false, reason: "digest" };
|
|
146
|
+
const object = await input.reader.read(input.pathname);
|
|
147
|
+
if (!object) return { ok: false, reason: "missing" };
|
|
148
|
+
if (object.body.byteLength === 0 || object.body.byteLength > input.maximumBytes) {
|
|
149
|
+
return { ok: false, reason: "size" };
|
|
150
|
+
}
|
|
151
|
+
const actual = createHash("sha256").update(object.body).digest();
|
|
152
|
+
const expected = Buffer.from(input.sha256, "hex");
|
|
153
|
+
if (actual.byteLength !== expected.byteLength || !timingSafeEqual(actual, expected)) {
|
|
154
|
+
return { ok: false, reason: "digest" };
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return { ok: true, script: new TextDecoder("utf-8", { fatal: true }).decode(object.body) };
|
|
158
|
+
} catch {
|
|
159
|
+
return { ok: false, reason: "encoding" };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function deleteScriptUpload(
|
|
164
|
+
token: string,
|
|
165
|
+
pathname: string,
|
|
166
|
+
delBlob?: PrivateDel,
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
if (token.length === 0) throw new Error("private store token is empty");
|
|
169
|
+
if (!isUploadPath(pathname)) throw new Error("upload path refused");
|
|
170
|
+
const remove: PrivateDel = delBlob ?? ((path, options) => del(path, options));
|
|
171
|
+
try {
|
|
172
|
+
await remove(pathname, { token });
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (err instanceof BlobNotFoundError) return;
|
|
175
|
+
throw new Error("private store delete failed");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function defaultPut(
|
|
180
|
+
pathname: string,
|
|
181
|
+
body: Buffer,
|
|
182
|
+
options: {
|
|
183
|
+
access: "private";
|
|
184
|
+
token: string;
|
|
185
|
+
contentType: typeof SCRIPT_UPLOAD_TYPE;
|
|
186
|
+
},
|
|
187
|
+
): Promise<unknown> {
|
|
188
|
+
return put(pathname, body, options);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function defaultGet(
|
|
192
|
+
pathname: string,
|
|
193
|
+
options: { access: "private"; token: string; useCache: false },
|
|
194
|
+
): Promise<PrivateGetResult> {
|
|
195
|
+
return get(pathname, options);
|
|
196
|
+
}
|
package/src/write.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { parseNetwork } from "@moloodev/platform/network";
|
|
2
|
+
import {
|
|
3
|
+
ARTIFACT_MAX_AGE,
|
|
4
|
+
encodePointer,
|
|
5
|
+
POINTER_MAX_AGE,
|
|
6
|
+
POINTER_TYPE,
|
|
7
|
+
pointerFor,
|
|
8
|
+
pointerPath,
|
|
9
|
+
SCRIPT_TYPE,
|
|
10
|
+
type ObjectWriter,
|
|
11
|
+
type Pointer,
|
|
12
|
+
} from "./pointer";
|
|
13
|
+
|
|
14
|
+
export async function putRevision(
|
|
15
|
+
writer: ObjectWriter,
|
|
16
|
+
input: {
|
|
17
|
+
readonly editorId: string;
|
|
18
|
+
readonly revisionId: string;
|
|
19
|
+
readonly script: Uint8Array;
|
|
20
|
+
readonly network: readonly string[];
|
|
21
|
+
},
|
|
22
|
+
): Promise<Pointer> {
|
|
23
|
+
const pointer = pointerFor(
|
|
24
|
+
input.editorId,
|
|
25
|
+
input.revisionId,
|
|
26
|
+
parseNetwork(input.network),
|
|
27
|
+
);
|
|
28
|
+
// Copy first. The pointer is written only after that write resolves, so a
|
|
29
|
+
// failed or partial copy cannot leave the pointer aimed at a short script.
|
|
30
|
+
// A later retry overwrites both and converges.
|
|
31
|
+
const script = new Uint8Array(input.script);
|
|
32
|
+
await writer.write(pointer.paths.script, script, {
|
|
33
|
+
contentType: SCRIPT_TYPE,
|
|
34
|
+
maxAge: ARTIFACT_MAX_AGE,
|
|
35
|
+
immutable: true,
|
|
36
|
+
});
|
|
37
|
+
await writer.write(pointerPath(input.editorId), encodePointer(pointer), {
|
|
38
|
+
contentType: POINTER_TYPE,
|
|
39
|
+
maxAge: POINTER_MAX_AGE,
|
|
40
|
+
immutable: false,
|
|
41
|
+
});
|
|
42
|
+
return pointer;
|
|
43
|
+
}
|
package/README.md
DELETED