lambder 8.0.2 → 8.1.1
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/CHANGELOG.md +115 -1
- package/README.md +7 -2
- package/dist/build/ContractTypePrinter.d.ts +85 -0
- package/dist/build/ContractTypePrinter.js +402 -0
- package/dist/build/moduleLocation.d.ts +11 -0
- package/dist/build/moduleLocation.js +6 -0
- package/dist/build/writeApiContract.d.ts +78 -0
- package/dist/build/writeApiContract.js +302 -0
- package/dist/build/writeApiSignatures.d.ts +32 -27
- package/dist/build/writeApiSignatures.js +37 -42
- package/dist/build/writeFileAtomically.d.ts +8 -0
- package/dist/build/writeFileAtomically.js +22 -0
- package/dist/build.d.ts +8 -3
- package/dist/build.js +6 -3
- package/dist/client/LambderUploadRunner.d.ts +96 -0
- package/dist/client/LambderUploadRunner.js +234 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +4 -0
- package/dist/core/Lambder.d.ts +9 -10
- package/dist/core/Lambder.js +9 -10
- package/dist/index.d.ts +11 -1
- package/dist/index.js +8 -0
- package/dist/mock/lambderMockMswHandler.d.ts +10 -4
- package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
- package/dist/mock/lambderMockUploadMswHandler.js +28 -0
- package/dist/mock.d.ts +3 -0
- package/dist/mock.js +4 -0
- package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
- package/dist/shared/contracts/LambderUploadBucket.js +74 -0
- package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
- package/dist/shared/util/LambderContentDisposition.js +13 -0
- package/dist/shared/util/LambderTextDigest.d.ts +7 -5
- package/dist/shared/util/LambderTextDigest.js +11 -5
- package/dist/shared/wire/LambderApiContract.d.ts +10 -40
- package/dist/shared/wire/LambderApiRefusal.d.ts +6 -0
- package/dist/shared/wire/LambderApiRefusal.js +6 -0
- package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
- package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
- package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
- package/dist/shared/wire/LambderUploadRefusal.js +18 -0
- package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
- package/dist/shared/wire/LambderUploadSchemas.js +30 -0
- package/dist/stores/LambderDdbSdk.js +1 -5
- package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
- package/dist/stores/LambderMemoryUploadBucket.js +219 -0
- package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
- package/dist/stores/LambderS3UploadBucket.js +144 -0
- package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
- package/dist/stores/LambderSdkInstallHint.js +14 -0
- package/dist/testing/LambderTestApp.d.ts +4 -4
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +1 -0
- package/package.json +15 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** What an app accepts for one kind of upload, declared once and read by both sides. */
|
|
2
|
+
export type LambderUploadRule = {
|
|
3
|
+
/** The largest file accepted, in bytes. */
|
|
4
|
+
maxBytes: number;
|
|
5
|
+
/** The accepted content types, exact, with no wildcards: `["application/pdf"]`. */
|
|
6
|
+
mimeTypes: readonly string[];
|
|
7
|
+
};
|
|
8
|
+
/** What the browser says about a file before any of its bytes move. */
|
|
9
|
+
export type LambderUploadFileFacts = {
|
|
10
|
+
fileName: string;
|
|
11
|
+
mimeType: string;
|
|
12
|
+
byteSize: number;
|
|
13
|
+
/** SHA-256 of the file's bytes as base64, the form storage checks an upload against: 43 characters and one pad. */
|
|
14
|
+
sha256Base64: string;
|
|
15
|
+
};
|
|
16
|
+
/** Everything the browser needs to post one file to storage, and until when. */
|
|
17
|
+
export type LambderUploadTicket = {
|
|
18
|
+
uploadUrl: string;
|
|
19
|
+
/** Sent as form fields ahead of the file, which storage wants last. */
|
|
20
|
+
formFields: Record<string, string>;
|
|
21
|
+
/** Epoch milliseconds after which storage refuses the ticket. */
|
|
22
|
+
expiresAt: number;
|
|
23
|
+
};
|
|
24
|
+
/** What a bucket holds under a key, compared with what the browser said it would upload. */
|
|
25
|
+
export type LambderUploadVerdict = {
|
|
26
|
+
verified: true;
|
|
27
|
+
}
|
|
28
|
+
/** `objectMissing`: nothing was posted. `factsMismatch`: something else sits under the key. */
|
|
29
|
+
| {
|
|
30
|
+
verified: false;
|
|
31
|
+
reason: "objectMissing" | "factsMismatch";
|
|
32
|
+
};
|
|
33
|
+
/** How a browser presents an object it reads: in place (a PDF in its viewer) or saved as a file, under a name. */
|
|
34
|
+
export type LambderUploadContentDisposition = {
|
|
35
|
+
disposition: "inline" | "attachment";
|
|
36
|
+
/** The name the file is saved or shown under; any characters, encoded for the header. Without one, the browser takes the key's last segment. */
|
|
37
|
+
fileName?: string;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* What storage keeps beside an object's bytes. A ticket pins every one of
|
|
41
|
+
* these in its signed policy, so the browser posts them unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export type LambderUploadObjectOptions = {
|
|
44
|
+
/**
|
|
45
|
+
* The object's tags, at most ten. They are how an object gets a time to
|
|
46
|
+
* live: S3 has no expiry per object, and a lifecycle rule keyed on a tag
|
|
47
|
+
* (`retention: "30d"` expiring after 30 days) deletes what carries it.
|
|
48
|
+
* Keys up to 128 characters, values up to 256.
|
|
49
|
+
*/
|
|
50
|
+
tags?: Record<string, string>;
|
|
51
|
+
/** User metadata, kept as `x-amz-meta-<name>` and returned with every read of the object. Names are lowercased; printable ASCII, 2 KB in all. */
|
|
52
|
+
metadata?: Record<string, string>;
|
|
53
|
+
/** The Cache-Control every read of the object answers with, for one served through a CDN. */
|
|
54
|
+
cacheControl?: string;
|
|
55
|
+
/** How a browser presents the object by default; a download link can say otherwise. */
|
|
56
|
+
contentDisposition?: LambderUploadContentDisposition;
|
|
57
|
+
};
|
|
58
|
+
/** The longest a ticket or a download link may live: S3's limit for a signature, seven days. */
|
|
59
|
+
export declare const UPLOAD_SIGNATURE_MAX_SECONDS: number;
|
|
60
|
+
/** Throws unless a lifetime is a positive number of seconds within S3's seven days. */
|
|
61
|
+
export declare const assertSignatureLifetime: (seconds: number, name: string) => void;
|
|
62
|
+
/** Throws when object options break a limit S3 holds them to, so the mistake shows where the app wrote it rather than as a refused post. */
|
|
63
|
+
export declare const assertObjectOptions: (options: LambderUploadObjectOptions | undefined) => void;
|
|
64
|
+
/** What a rule holds against a file, known before any of it is sent. */
|
|
65
|
+
export type LambderUploadRuleVerdict = "fileEmpty" | "fileTypeRejected" | "fileTooLarge";
|
|
66
|
+
/** A rule's verdict on a file, or null when it may be uploaded: the check the browser makes before hashing and the bucket makes before signing. */
|
|
67
|
+
export declare const checkUploadRule: (rule: LambderUploadRule, file: {
|
|
68
|
+
mimeType: string;
|
|
69
|
+
byteSize: number;
|
|
70
|
+
}) => LambderUploadRuleVerdict | null;
|
|
71
|
+
/**
|
|
72
|
+
* Throws when an object key would not pin the key a ticket writes to: S3
|
|
73
|
+
* substitutes the uploaded file's own name for `${filename}` in a presigned
|
|
74
|
+
* POST's key, so a key holding it lets the browser choose where under it the
|
|
75
|
+
* file lands. A key is the app's, built from its own ids, so this is a
|
|
76
|
+
* programming error rather than a refusal.
|
|
77
|
+
*/
|
|
78
|
+
export declare const assertPinnedObjectKey: (objectKey: string) => void;
|
|
79
|
+
/**
|
|
80
|
+
* Object storage a browser uploads to directly, with tickets the server
|
|
81
|
+
* signs, and that the server reads, writes and deletes through for the rest
|
|
82
|
+
* of an object's life. One instance per bucket. LambderS3UploadBucket in
|
|
83
|
+
* production; LambderMemoryUploadBucket in tests and the mock runtime.
|
|
84
|
+
*
|
|
85
|
+
* The object key is always chosen by the app from its own ids. It never
|
|
86
|
+
* comes from the browser, and a ticket pins it, so a ticket cannot be used to
|
|
87
|
+
* write anywhere else.
|
|
88
|
+
*/
|
|
89
|
+
export interface LambderUploadBucket {
|
|
90
|
+
/**
|
|
91
|
+
* Signs a ticket for exactly the file the browser described, or refuses
|
|
92
|
+
* (a LambderApiRefusal, code `lambder/upload-empty`,
|
|
93
|
+
* `lambder/upload-type-rejected` or `lambder/upload-too-large`) when the
|
|
94
|
+
* rule does not accept it. Storage then enforces every fact: the post
|
|
95
|
+
* fails unless the body has that byte size, that content type and that
|
|
96
|
+
* SHA-256, so what verifies later is what was described here. `object`
|
|
97
|
+
* is what the stored object carries besides, pinned the same way, and
|
|
98
|
+
* `lifetimeSeconds` overrides the bucket's ticket lifetime for this one.
|
|
99
|
+
*/
|
|
100
|
+
issueUploadTicket(options: {
|
|
101
|
+
objectKey: string;
|
|
102
|
+
fileFacts: LambderUploadFileFacts;
|
|
103
|
+
uploadRule: LambderUploadRule;
|
|
104
|
+
lifetimeSeconds?: number;
|
|
105
|
+
object?: LambderUploadObjectOptions;
|
|
106
|
+
}): Promise<LambderUploadTicket>;
|
|
107
|
+
/**
|
|
108
|
+
* Asks storage what it holds under the key. The browser saying "done"
|
|
109
|
+
* proves nothing, so an app calls this before its record counts as
|
|
110
|
+
* uploaded.
|
|
111
|
+
*/
|
|
112
|
+
verifyUploadedObject(options: {
|
|
113
|
+
objectKey: string;
|
|
114
|
+
fileFacts: Pick<LambderUploadFileFacts, "byteSize" | "sha256Base64">;
|
|
115
|
+
}): Promise<LambderUploadVerdict>;
|
|
116
|
+
/**
|
|
117
|
+
* A link the browser can read the object with (a preview, a download)
|
|
118
|
+
* until it expires: `lifetimeSeconds`, or the bucket's link lifetime.
|
|
119
|
+
* `contentDisposition` decides for this link whether the browser shows
|
|
120
|
+
* the file or saves it, and under what name.
|
|
121
|
+
*/
|
|
122
|
+
issueDownloadUrl(options: {
|
|
123
|
+
objectKey: string;
|
|
124
|
+
lifetimeSeconds?: number;
|
|
125
|
+
contentDisposition?: LambderUploadContentDisposition;
|
|
126
|
+
}): Promise<string>;
|
|
127
|
+
/** The object's bytes, for work the server does on a file itself. Throws when the key holds nothing. */
|
|
128
|
+
readObject(objectKey: string): Promise<Uint8Array>;
|
|
129
|
+
/**
|
|
130
|
+
* Stores bytes the server produced, with their SHA-256 checked by storage
|
|
131
|
+
* on the way in, as a ticket checks a browser's upload. Pass the digest
|
|
132
|
+
* when it is already known; otherwise it is computed. `object` is what
|
|
133
|
+
* the object carries besides, as for a ticket.
|
|
134
|
+
*/
|
|
135
|
+
writeObject(options: {
|
|
136
|
+
objectKey: string;
|
|
137
|
+
body: Uint8Array;
|
|
138
|
+
mimeType: string;
|
|
139
|
+
sha256Base64?: string;
|
|
140
|
+
object?: LambderUploadObjectOptions;
|
|
141
|
+
}): Promise<void>;
|
|
142
|
+
/**
|
|
143
|
+
* A second object with the same bytes, made inside storage: nothing is
|
|
144
|
+
* read into the function, so a file of any size copies in one call. For
|
|
145
|
+
* records that must each own their file, so deleting one never takes the
|
|
146
|
+
* other's. The copy carries the source's type, metadata and tags.
|
|
147
|
+
*/
|
|
148
|
+
copyObject(options: {
|
|
149
|
+
fromObjectKey: string;
|
|
150
|
+
toObjectKey: string;
|
|
151
|
+
}): Promise<void>;
|
|
152
|
+
/** Removes the object. Deleting a key that holds nothing is not an error. */
|
|
153
|
+
deleteObject(objectKey: string): Promise<void>;
|
|
154
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Direct uploads: a file that travels from the browser straight to object
|
|
3
|
+
* storage, never through the app's function.
|
|
4
|
+
*
|
|
5
|
+
* An API payload tops out near a few megabytes once a file is base64, and a
|
|
6
|
+
* Lambda's request body at six, so anything larger (a scanned lease, a
|
|
7
|
+
* signed PDF, a video) is posted by the browser to the bucket itself, with a
|
|
8
|
+
* ticket the server signed beforehand. The ticket pins everything about the
|
|
9
|
+
* upload: the key, the exact byte size, the content type and the SHA-256 of
|
|
10
|
+
* the bytes, all enforced by the storage, so the browser can only ever store
|
|
11
|
+
* the one file it described.
|
|
12
|
+
*
|
|
13
|
+
* The conversation is the same three steps whatever the app stores: the
|
|
14
|
+
* browser describes the file (LambderUploadFileFacts), the app's endpoint
|
|
15
|
+
* answers with a ticket (LambderUploadTicket), and after the post the app's
|
|
16
|
+
* confirm endpoint asks the bucket what arrived before its record counts as
|
|
17
|
+
* uploaded. The server half is a LambderUploadBucket, the browser half is
|
|
18
|
+
* LambderUploadRunner.
|
|
19
|
+
*
|
|
20
|
+
* This module is the vocabulary both halves share and the contract a bucket
|
|
21
|
+
* implements, and it imports nothing: the zod schemas an endpoint declares
|
|
22
|
+
* its input and output with are in wire/LambderUploadSchemas.ts.
|
|
23
|
+
*/
|
|
24
|
+
/** The longest a ticket or a download link may live: S3's limit for a signature, seven days. */
|
|
25
|
+
export const UPLOAD_SIGNATURE_MAX_SECONDS = 7 * 24 * 60 * 60;
|
|
26
|
+
/** Throws unless a lifetime is a positive number of seconds within S3's seven days. */
|
|
27
|
+
export const assertSignatureLifetime = (seconds, name) => {
|
|
28
|
+
if (!(Number.isFinite(seconds) && seconds > 0 && seconds <= UPLOAD_SIGNATURE_MAX_SECONDS)) {
|
|
29
|
+
throw new RangeError(`${name} must be a number of seconds above 0 and at most ${UPLOAD_SIGNATURE_MAX_SECONDS} (seven days): ${seconds}`);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/** Throws when object options break a limit S3 holds them to, so the mistake shows where the app wrote it rather than as a refused post. */
|
|
33
|
+
export const assertObjectOptions = (options) => {
|
|
34
|
+
const tags = Object.entries(options?.tags ?? {});
|
|
35
|
+
if (tags.length > 10)
|
|
36
|
+
throw new RangeError(`An object carries at most 10 tags: ${tags.length}`);
|
|
37
|
+
for (const [key, value] of tags) {
|
|
38
|
+
if (!key || key.length > 128 || value.length > 256)
|
|
39
|
+
throw new RangeError(`A tag's key is 1 to 128 characters and its value at most 256: ${key}`);
|
|
40
|
+
}
|
|
41
|
+
let metadataBytes = 0;
|
|
42
|
+
for (const [name, value] of Object.entries(options?.metadata ?? {})) {
|
|
43
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name))
|
|
44
|
+
throw new RangeError(`A metadata name is letters, digits, "-" and "_": ${name}`);
|
|
45
|
+
if (!/^[\x20-\x7e]*$/.test(value))
|
|
46
|
+
throw new RangeError(`A metadata value is printable ASCII: ${name}`);
|
|
47
|
+
metadataBytes += name.length + value.length;
|
|
48
|
+
}
|
|
49
|
+
if (metadataBytes > 2048)
|
|
50
|
+
throw new RangeError(`An object's metadata is at most 2 KB: ${metadataBytes} bytes`);
|
|
51
|
+
};
|
|
52
|
+
/** A rule's verdict on a file, or null when it may be uploaded: the check the browser makes before hashing and the bucket makes before signing. */
|
|
53
|
+
export const checkUploadRule = (rule, file) => {
|
|
54
|
+
if (file.byteSize <= 0)
|
|
55
|
+
return "fileEmpty";
|
|
56
|
+
if (!rule.mimeTypes.includes(file.mimeType))
|
|
57
|
+
return "fileTypeRejected";
|
|
58
|
+
if (file.byteSize > rule.maxBytes)
|
|
59
|
+
return "fileTooLarge";
|
|
60
|
+
return null;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Throws when an object key would not pin the key a ticket writes to: S3
|
|
64
|
+
* substitutes the uploaded file's own name for `${filename}` in a presigned
|
|
65
|
+
* POST's key, so a key holding it lets the browser choose where under it the
|
|
66
|
+
* file lands. A key is the app's, built from its own ids, so this is a
|
|
67
|
+
* programming error rather than a refusal.
|
|
68
|
+
*/
|
|
69
|
+
export const assertPinnedObjectKey = (objectKey) => {
|
|
70
|
+
if (!objectKey)
|
|
71
|
+
throw new Error("An upload's object key is empty");
|
|
72
|
+
if (objectKey.includes("${filename}"))
|
|
73
|
+
throw new Error(`An upload's object key may not hold \${filename}, which storage replaces with the uploaded file's name: ${objectKey}`);
|
|
74
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Content-Disposition header for a file name of any characters: an ASCII
|
|
3
|
+
* `filename` for old clients, and the exact name as UTF-8 in `filename*`
|
|
4
|
+
* (RFC 6266). A character a header cannot carry never reaches it, so a name
|
|
5
|
+
* holding a quote or a line break cannot end the header early.
|
|
6
|
+
*/
|
|
7
|
+
export declare const contentDispositionHeader: ({ disposition, fileName }: {
|
|
8
|
+
disposition: "inline" | "attachment";
|
|
9
|
+
fileName?: string;
|
|
10
|
+
}) => string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Content-Disposition header for a file name of any characters: an ASCII
|
|
3
|
+
* `filename` for old clients, and the exact name as UTF-8 in `filename*`
|
|
4
|
+
* (RFC 6266). A character a header cannot carry never reaches it, so a name
|
|
5
|
+
* holding a quote or a line break cannot end the header early.
|
|
6
|
+
*/
|
|
7
|
+
export const contentDispositionHeader = ({ disposition, fileName }) => {
|
|
8
|
+
if (fileName === undefined)
|
|
9
|
+
return disposition;
|
|
10
|
+
const fallback = fileName.replace(/[^\x20-\x7e]|["\\%]/g, "_");
|
|
11
|
+
const encoded = encodeURIComponent(fileName).replace(/['()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
12
|
+
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
|
13
|
+
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SHA-256
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* SHA-256 through WebCrypto: the one digest every layer shares. The session
|
|
3
|
+
* crypto hashes bearer secrets with it and the rate-limit engine folds an
|
|
4
|
+
* over-long tracker key with it, so both key spaces are built from the same
|
|
5
|
+
* primitive on every runtime (browsers on a secure context, Node 20+, edge
|
|
6
|
+
* runtimes); an upload's checksum is the same digest over the file's bytes.
|
|
7
7
|
*/
|
|
8
8
|
/** Lowercase hex of a byte array, two characters per byte. */
|
|
9
9
|
export declare const bytesToHexString: (bytes: Uint8Array) => string;
|
|
@@ -15,3 +15,5 @@ export declare const bytesToHexString: (bytes: Uint8Array) => string;
|
|
|
15
15
|
export declare const resolveWebCrypto: () => Promise<Crypto>;
|
|
16
16
|
/** The SHA-256 digest of `text` (UTF-8), as 64 lowercase hex characters. */
|
|
17
17
|
export declare const sha256HexOf: (text: string) => Promise<string>;
|
|
18
|
+
/** The SHA-256 digest of `bytes`, as base64: the form object storage checks an upload's checksum in. */
|
|
19
|
+
export declare const sha256Base64Of: (bytes: Uint8Array) => Promise<string>;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { bytesToBase64 } from "./LambderBase64.js";
|
|
1
2
|
import { getCrypto } from "./LambderNodeModules.js";
|
|
2
3
|
/**
|
|
3
|
-
* SHA-256
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* SHA-256 through WebCrypto: the one digest every layer shares. The session
|
|
5
|
+
* crypto hashes bearer secrets with it and the rate-limit engine folds an
|
|
6
|
+
* over-long tracker key with it, so both key spaces are built from the same
|
|
7
|
+
* primitive on every runtime (browsers on a secure context, Node 20+, edge
|
|
8
|
+
* runtimes); an upload's checksum is the same digest over the file's bytes.
|
|
8
9
|
*/
|
|
9
10
|
/** Lowercase hex of a byte array, two characters per byte. */
|
|
10
11
|
export const bytesToHexString = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
@@ -32,3 +33,8 @@ export const sha256HexOf = async (text) => {
|
|
|
32
33
|
const digest = await webCrypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
33
34
|
return bytesToHexString(new Uint8Array(digest));
|
|
34
35
|
};
|
|
36
|
+
/** The SHA-256 digest of `bytes`, as base64: the form object storage checks an upload's checksum in. */
|
|
37
|
+
export const sha256Base64Of = async (bytes) => {
|
|
38
|
+
const webCrypto = await resolveWebCrypto();
|
|
39
|
+
return bytesToBase64(new Uint8Array(await webCrypto.subtle.digest("SHA-256", bytes)));
|
|
40
|
+
};
|
|
@@ -148,49 +148,19 @@ export type LambderContractEntry<In, Out, Mode extends LambderApiMode, GuardInpu
|
|
|
148
148
|
}) & ([Idempotency] extends [never] ? {} : {
|
|
149
149
|
idempotency: Idempotency;
|
|
150
150
|
});
|
|
151
|
-
/** Helper type for merging a new entry into the contract during chaining. */
|
|
152
|
-
export type LambderMergeContract<Old, Name extends string, Entry> = Old & {
|
|
153
|
-
[K in Name]: Entry;
|
|
154
|
-
};
|
|
155
151
|
/**
|
|
156
|
-
*
|
|
157
|
-
* app declares its contract through:
|
|
158
|
-
*
|
|
159
|
-
* ```ts
|
|
160
|
-
* export interface ApiContractType extends LambderFlattenContract<typeof lambder.ApiContract> {}
|
|
161
|
-
* ```
|
|
152
|
+
* Merges a new entry into the contract during chaining.
|
|
162
153
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* resolves the property across all of them.
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* An interface's members are declared, so they resolve once for the whole
|
|
171
|
-
* declaration: the same access costs ~6 instantiations instead, roughly
|
|
172
|
-
* halving such an app's frontend type check time. The alias form
|
|
173
|
-
* (`type C = LambderFlattenContract<...>`) does NOT do this: a mapped type
|
|
174
|
-
* stays deferred and each lookup pays in full, so the `interface ... extends`
|
|
175
|
-
* spelling is the point. Diagnostics also print the interface by name rather
|
|
176
|
-
* than a truncated spill of entries.
|
|
177
|
-
*
|
|
178
|
-
* Every endpoint name must be a string literal for an interface to extend
|
|
179
|
-
* the result, which registration through addApi/addSessionApi guarantees.
|
|
180
|
-
*
|
|
181
|
-
* Two things that look like tidying undo it:
|
|
182
|
-
*
|
|
183
|
-
* - `@typescript-eslint/no-empty-object-type` reports the empty body as
|
|
184
|
-
* "equivalent to its supertype" and its fix is a type alias, the one
|
|
185
|
-
* spelling that collapses nothing. Disable the rule on the line instead.
|
|
186
|
-
* - Extending anything but a mapped type loses the inferable index signature.
|
|
187
|
-
* A hand-written `interface C { ... }` has none, so it is not assignable to
|
|
188
|
-
* LambderApiContractShape, and initLambderMock<C>, LambderCaller<C> and
|
|
189
|
-
* LambderInvokeCaller<C> reject it. api-contract.test.ts pins this, and
|
|
190
|
-
* that the flattened contract is the same type member for member.
|
|
154
|
+
* The contract is therefore an intersection one member deep per endpoint, and
|
|
155
|
+
* every `C[K]` read generically (a typed caller, a mock registry, a test
|
|
156
|
+
* visitor) resolves the property across all of them. That costs nothing
|
|
157
|
+
* worth measuring in a small app and most of a large client's type check,
|
|
158
|
+
* which is what writeApiContract (lambder/build) is for: it writes the
|
|
159
|
+
* contract out as one object type with plain members, for clients to import
|
|
160
|
+
* instead of the server.
|
|
191
161
|
*/
|
|
192
|
-
export type
|
|
193
|
-
[K in
|
|
162
|
+
export type LambderMergeContract<Old, Name extends string, Entry> = Old & {
|
|
163
|
+
[K in Name]: Entry;
|
|
194
164
|
};
|
|
195
165
|
/** Guard names referenced by a guards option, whichever of its three forms is used. */
|
|
196
166
|
export type LambderGuardNamesIn<TOpt> = TOpt extends string ? TOpt : TOpt extends readonly (infer N extends string)[] ? N : TOpt extends object ? keyof TOpt & string : never;
|
|
@@ -123,6 +123,12 @@ export declare const LAMBDER_REFUSAL_CODES: {
|
|
|
123
123
|
readonly invalidRequestPayload: "lambder/invalid-request-payload";
|
|
124
124
|
/** Only the mock runtime emits it: the endpoint is registered as not mocked, with a reason. */
|
|
125
125
|
readonly notMocked: "lambder/not-mocked";
|
|
126
|
+
/** An upload bucket would not sign a ticket for a file with no bytes. */
|
|
127
|
+
readonly uploadEmpty: "lambder/upload-empty";
|
|
128
|
+
/** An upload bucket would not sign a ticket for a content type the rule does not accept. */
|
|
129
|
+
readonly uploadTypeRejected: "lambder/upload-type-rejected";
|
|
130
|
+
/** An upload bucket would not sign a ticket for a file larger than the rule accepts. */
|
|
131
|
+
readonly uploadTooLarge: "lambder/upload-too-large";
|
|
126
132
|
};
|
|
127
133
|
export type LambderRefusalCode = (typeof LAMBDER_REFUSAL_CODES)[keyof typeof LAMBDER_REFUSAL_CODES];
|
|
128
134
|
export type LambderRefuseOptions = {
|
|
@@ -86,6 +86,12 @@ export const LAMBDER_REFUSAL_CODES = {
|
|
|
86
86
|
invalidRequestPayload: "lambder/invalid-request-payload",
|
|
87
87
|
/** Only the mock runtime emits it: the endpoint is registered as not mocked, with a reason. */
|
|
88
88
|
notMocked: "lambder/not-mocked",
|
|
89
|
+
/** An upload bucket would not sign a ticket for a file with no bytes. */
|
|
90
|
+
uploadEmpty: "lambder/upload-empty",
|
|
91
|
+
/** An upload bucket would not sign a ticket for a content type the rule does not accept. */
|
|
92
|
+
uploadTypeRejected: "lambder/upload-type-rejected",
|
|
93
|
+
/** An upload bucket would not sign a ticket for a file larger than the rule accepts. */
|
|
94
|
+
uploadTooLarge: "lambder/upload-too-large",
|
|
89
95
|
};
|
|
90
96
|
/**
|
|
91
97
|
* Refuse the current API call: a routine business "no" (not found, invalid
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LambderUploadObjectOptions } from "../contracts/LambderUploadBucket.js";
|
|
2
|
+
/**
|
|
3
|
+
* What a ticket's form carries for the stored object, in the fields S3's
|
|
4
|
+
* presigned POST reads them from: the tag set as the XML `tagging` field,
|
|
5
|
+
* each metadata entry as `x-amz-meta-<name>` (lowercased, as S3 keeps it),
|
|
6
|
+
* `Cache-Control` and `Content-Disposition`. Both buckets build their
|
|
7
|
+
* tickets' fields with this, so a post carries the same form to either, and
|
|
8
|
+
* every field is pinned by the ticket like the key and the checksum.
|
|
9
|
+
*/
|
|
10
|
+
export declare const uploadObjectFormFields: (object: LambderUploadObjectOptions | undefined) => Record<string, string>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { contentDispositionHeader } from "../util/LambderContentDisposition.js";
|
|
2
|
+
const escapeXml = (text) => text.replace(/[<>&'"]/g, (character) => `&#${character.charCodeAt(0)};`);
|
|
3
|
+
/**
|
|
4
|
+
* What a ticket's form carries for the stored object, in the fields S3's
|
|
5
|
+
* presigned POST reads them from: the tag set as the XML `tagging` field,
|
|
6
|
+
* each metadata entry as `x-amz-meta-<name>` (lowercased, as S3 keeps it),
|
|
7
|
+
* `Cache-Control` and `Content-Disposition`. Both buckets build their
|
|
8
|
+
* tickets' fields with this, so a post carries the same form to either, and
|
|
9
|
+
* every field is pinned by the ticket like the key and the checksum.
|
|
10
|
+
*/
|
|
11
|
+
export const uploadObjectFormFields = (object) => {
|
|
12
|
+
const fields = {};
|
|
13
|
+
const tags = Object.entries(object?.tags ?? {});
|
|
14
|
+
if (tags.length) {
|
|
15
|
+
fields.tagging = `<Tagging><TagSet>${tags.map(([key, value]) => `<Tag><Key>${escapeXml(key)}</Key><Value>${escapeXml(value)}</Value></Tag>`).join("")}</TagSet></Tagging>`;
|
|
16
|
+
}
|
|
17
|
+
for (const [name, value] of Object.entries(object?.metadata ?? {}))
|
|
18
|
+
fields[`x-amz-meta-${name.toLowerCase()}`] = value;
|
|
19
|
+
if (object?.cacheControl !== undefined)
|
|
20
|
+
fields["Cache-Control"] = object.cacheControl;
|
|
21
|
+
if (object?.contentDisposition)
|
|
22
|
+
fields["Content-Disposition"] = contentDispositionHeader(object.contentDisposition);
|
|
23
|
+
return fields;
|
|
24
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type LambderUploadFileFacts, type LambderUploadRule } from "../contracts/LambderUploadBucket.js";
|
|
2
|
+
/**
|
|
3
|
+
* Refuses the API call signing a ticket when the rule does not accept the
|
|
4
|
+
* file, with the refusal code a client branches and translates on. Both of
|
|
5
|
+
* Lambder's buckets sign through this, and a bucket of an app's own (another
|
|
6
|
+
* store behind LambderUploadBucket) calls it the same way, so a file is
|
|
7
|
+
* refused alike whichever storage an app runs on.
|
|
8
|
+
*/
|
|
9
|
+
export declare const refuseUnacceptedUpload: (uploadRule: LambderUploadRule, fileFacts: Pick<LambderUploadFileFacts, "mimeType" | "byteSize">) => void;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { checkUploadRule } from "../contracts/LambderUploadBucket.js";
|
|
2
|
+
import { LAMBDER_REFUSAL_CODES, refuse } from "./LambderApiRefusal.js";
|
|
3
|
+
/**
|
|
4
|
+
* Refuses the API call signing a ticket when the rule does not accept the
|
|
5
|
+
* file, with the refusal code a client branches and translates on. Both of
|
|
6
|
+
* Lambder's buckets sign through this, and a bucket of an app's own (another
|
|
7
|
+
* store behind LambderUploadBucket) calls it the same way, so a file is
|
|
8
|
+
* refused alike whichever storage an app runs on.
|
|
9
|
+
*/
|
|
10
|
+
export const refuseUnacceptedUpload = (uploadRule, fileFacts) => {
|
|
11
|
+
const verdict = checkUploadRule(uploadRule, fileFacts);
|
|
12
|
+
if (verdict === "fileEmpty")
|
|
13
|
+
refuse("This file is empty.", { code: LAMBDER_REFUSAL_CODES.uploadEmpty });
|
|
14
|
+
if (verdict === "fileTypeRejected")
|
|
15
|
+
refuse("This type of file is not accepted.", { code: LAMBDER_REFUSAL_CODES.uploadTypeRejected });
|
|
16
|
+
if (verdict === "fileTooLarge")
|
|
17
|
+
refuse("This file is too large.", { code: LAMBDER_REFUSAL_CODES.uploadTooLarge });
|
|
18
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const LambderUploadFileFactsSchema: z.ZodObject<{
|
|
3
|
+
fileName: z.ZodString;
|
|
4
|
+
mimeType: z.ZodString;
|
|
5
|
+
byteSize: z.ZodNumber;
|
|
6
|
+
sha256Base64: z.ZodString;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
export declare const LambderUploadTicketSchema: z.ZodObject<{
|
|
9
|
+
uploadUrl: z.ZodURL;
|
|
10
|
+
formFields: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
11
|
+
expiresAt: z.ZodNumber;
|
|
12
|
+
}, z.core.$strip>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/*
|
|
3
|
+
* The two shapes of a direct upload that cross an app's own API, as schemas
|
|
4
|
+
* an endpoint declares its input and output with: the file facts the browser
|
|
5
|
+
* posts to the ticket endpoint, and the ticket it answers.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* .addSessionApi("documents.requestUpload", {
|
|
9
|
+
* input: z.object({ folderId: z.uuid(), fileFacts: LambderUploadFileFactsSchema }),
|
|
10
|
+
* output: z.object({ ticket: LambderUploadTicketSchema, documentId: z.uuid() }),
|
|
11
|
+
* }, ...)
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* The types they infer are the contract's own (contracts/LambderUploadBucket.ts),
|
|
15
|
+
* which the compiler holds them to below in both directions.
|
|
16
|
+
*/
|
|
17
|
+
export const LambderUploadFileFactsSchema = z.object({
|
|
18
|
+
fileName: z.string().min(1).max(255),
|
|
19
|
+
mimeType: z.string().min(1).max(100),
|
|
20
|
+
byteSize: z.number().int().positive(),
|
|
21
|
+
/** SHA-256 of the file's bytes as base64: 32 bytes are always 43 characters and one pad. */
|
|
22
|
+
sha256Base64: z.string().regex(/^[A-Za-z0-9+/]{43}=$/),
|
|
23
|
+
});
|
|
24
|
+
export const LambderUploadTicketSchema = z.object({
|
|
25
|
+
uploadUrl: z.url(),
|
|
26
|
+
/** Sent as form fields ahead of the file, which storage wants last. */
|
|
27
|
+
formFields: z.record(z.string(), z.string()),
|
|
28
|
+
/** Epoch milliseconds after which storage refuses the ticket. */
|
|
29
|
+
expiresAt: z.number().int(),
|
|
30
|
+
});
|
|
@@ -15,13 +15,9 @@
|
|
|
15
15
|
* too: a conditional write's refusal is an answer rather than a failure, and
|
|
16
16
|
* a partition key has a limit that keys built from caller data can pass.
|
|
17
17
|
*/
|
|
18
|
+
import { withInstallHint } from "./LambderSdkInstallHint.js";
|
|
18
19
|
let clientSdk;
|
|
19
20
|
let documentSdk;
|
|
20
|
-
const withInstallHint = (loading, packageName, user, reset) => loading.catch((cause) => {
|
|
21
|
-
// Not memoized: the package may be installed later in the same process (tests), and the next caller names itself.
|
|
22
|
-
reset();
|
|
23
|
-
throw new Error(`${user} requires ${packageName}: npm install ${packageName}`, { cause });
|
|
24
|
-
});
|
|
25
21
|
/** `@aws-sdk/client-dynamodb`, for the item-level API the stores speak and the client the session manager wraps. */
|
|
26
22
|
const loadDynamoClientSdk = (user) => {
|
|
27
23
|
clientSdk ??= withInstallHint(import("@aws-sdk/client-dynamodb"), "@aws-sdk/client-dynamodb", user, () => { clientSdk = undefined; });
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type LambderUploadBucket, type LambderUploadContentDisposition, type LambderUploadFileFacts, type LambderUploadObjectOptions, type LambderUploadRule, type LambderUploadTicket, type LambderUploadVerdict } from "../shared/contracts/LambderUploadBucket.js";
|
|
2
|
+
export type LambderMemoryUploadBucketOptions = {
|
|
3
|
+
/**
|
|
4
|
+
* The URL tickets and download links point under, which a mock's MSW
|
|
5
|
+
* handler (lambderMockUploadMswHandler) or a test's fetch stub answers
|
|
6
|
+
* for. Default: a URL of its own per bucket, on a host that cannot
|
|
7
|
+
* resolve (`https://upload-bucket-<id>.invalid/`), so a request nothing
|
|
8
|
+
* intercepts fails rather than reaching the network.
|
|
9
|
+
*/
|
|
10
|
+
baseUrl?: string;
|
|
11
|
+
/** How long a ticket stays usable, unless a ticket says otherwise. Default: 600 seconds, as LambderS3UploadBucket. */
|
|
12
|
+
ticketLifetimeSeconds?: number;
|
|
13
|
+
/** How long a download link reads the object, unless a link says otherwise. Default: 300 seconds, as LambderS3UploadBucket. */
|
|
14
|
+
downloadLifetimeSeconds?: number;
|
|
15
|
+
/** The clock tickets and links expire by, injectable so a test can move past an expiry without waiting. Default: Date.now. */
|
|
16
|
+
now?: () => number;
|
|
17
|
+
};
|
|
18
|
+
/** What the memory bucket holds under a key, for a test to assert on: the object's facts and what it carries. */
|
|
19
|
+
export type LambderMemoryUploadObject = {
|
|
20
|
+
byteSize: number;
|
|
21
|
+
mimeType: string;
|
|
22
|
+
sha256Base64: string;
|
|
23
|
+
} & LambderUploadObjectOptions;
|
|
24
|
+
/**
|
|
25
|
+
* An upload bucket in memory (see LambderUploadBucket), for tests and the
|
|
26
|
+
* mock runtime.
|
|
27
|
+
*
|
|
28
|
+
* It holds a post to the rules S3 holds a presigned POST to: every field the
|
|
29
|
+
* ticket carries, each with the ticket's value, and no other, all ahead of
|
|
30
|
+
* the file (as S3 does, anything after the file is ignored); a ticket it
|
|
31
|
+
* issued and not yet expired; a body of exactly the size described; and bytes
|
|
32
|
+
* whose SHA-256 is the one described. It refuses otherwise with the status
|
|
33
|
+
* and the XML error S3 answers with (AccessDenied for a policy, and "Policy
|
|
34
|
+
* expired" for a late one, EntityTooSmall, EntityTooLarge, BadDigest), so any
|
|
35
|
+
* client, a LambderUploadRunner or another, takes the same path against it as
|
|
36
|
+
* against S3: an expired ticket is asked for again, a wrong file is refused.
|
|
37
|
+
* A download link reads the object until it expires.
|
|
38
|
+
*
|
|
39
|
+
* Storage requests reach it through handleStorageRequest(), which answers a
|
|
40
|
+
* fetch Request with a Response: lambderMockUploadMswHandler plugs that into
|
|
41
|
+
* MSW, and a test can route a stubbed fetch to it directly. Everything it
|
|
42
|
+
* uses is a web API (fetch's Request and Response, FormData, WebCrypto), so it
|
|
43
|
+
* runs in a browser, a service worker and Node alike.
|
|
44
|
+
*/
|
|
45
|
+
export declare class LambderMemoryUploadBucket implements LambderUploadBucket {
|
|
46
|
+
/** Where tickets and download links point, always ending in a slash. */
|
|
47
|
+
readonly baseUrl: string;
|
|
48
|
+
private readonly ticketLifetimeSeconds;
|
|
49
|
+
private readonly downloadLifetimeSeconds;
|
|
50
|
+
private readonly now;
|
|
51
|
+
private readonly objects;
|
|
52
|
+
private readonly tickets;
|
|
53
|
+
private readonly links;
|
|
54
|
+
constructor({ baseUrl, ticketLifetimeSeconds, downloadLifetimeSeconds, now }?: LambderMemoryUploadBucketOptions);
|
|
55
|
+
issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds, object }: {
|
|
56
|
+
objectKey: string;
|
|
57
|
+
fileFacts: LambderUploadFileFacts;
|
|
58
|
+
uploadRule: LambderUploadRule;
|
|
59
|
+
lifetimeSeconds?: number;
|
|
60
|
+
object?: LambderUploadObjectOptions;
|
|
61
|
+
}): Promise<LambderUploadTicket>;
|
|
62
|
+
verifyUploadedObject({ objectKey, fileFacts }: {
|
|
63
|
+
objectKey: string;
|
|
64
|
+
fileFacts: Pick<LambderUploadFileFacts, "byteSize" | "sha256Base64">;
|
|
65
|
+
}): Promise<LambderUploadVerdict>;
|
|
66
|
+
issueDownloadUrl({ objectKey, lifetimeSeconds, contentDisposition }: {
|
|
67
|
+
objectKey: string;
|
|
68
|
+
lifetimeSeconds?: number;
|
|
69
|
+
contentDisposition?: LambderUploadContentDisposition;
|
|
70
|
+
}): Promise<string>;
|
|
71
|
+
readObject(objectKey: string): Promise<Uint8Array>;
|
|
72
|
+
writeObject({ objectKey, body, mimeType, sha256Base64, object }: {
|
|
73
|
+
objectKey: string;
|
|
74
|
+
body: Uint8Array;
|
|
75
|
+
mimeType: string;
|
|
76
|
+
sha256Base64?: string;
|
|
77
|
+
object?: LambderUploadObjectOptions;
|
|
78
|
+
}): Promise<void>;
|
|
79
|
+
copyObject({ fromObjectKey, toObjectKey }: {
|
|
80
|
+
fromObjectKey: string;
|
|
81
|
+
toObjectKey: string;
|
|
82
|
+
}): Promise<void>;
|
|
83
|
+
deleteObject(objectKey: string): Promise<void>;
|
|
84
|
+
/** The keys that hold an object, sorted, for a test to assert on. */
|
|
85
|
+
listObjectKeys(): string[];
|
|
86
|
+
/** What is held under a key (its facts, tags, metadata and headers), for a test to assert on; null when nothing is. */
|
|
87
|
+
inspectObject(objectKey: string): LambderMemoryUploadObject | null;
|
|
88
|
+
/** Forgets every object, ticket and link. */
|
|
89
|
+
reset(): void;
|
|
90
|
+
/**
|
|
91
|
+
* Answers a request to storage the way S3 answers it: a post under a
|
|
92
|
+
* ticket stores its file, a GET or HEAD through a download link reads an
|
|
93
|
+
* object. A request outside baseUrl answers null, for the caller to hand
|
|
94
|
+
* on.
|
|
95
|
+
*/
|
|
96
|
+
handleStorageRequest(request: Request): Promise<Response | null>;
|
|
97
|
+
private acceptUpload;
|
|
98
|
+
private serveDownload;
|
|
99
|
+
}
|