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.
Files changed (53) hide show
  1. package/CHANGELOG.md +115 -1
  2. package/README.md +7 -2
  3. package/dist/build/ContractTypePrinter.d.ts +85 -0
  4. package/dist/build/ContractTypePrinter.js +402 -0
  5. package/dist/build/moduleLocation.d.ts +11 -0
  6. package/dist/build/moduleLocation.js +6 -0
  7. package/dist/build/writeApiContract.d.ts +78 -0
  8. package/dist/build/writeApiContract.js +302 -0
  9. package/dist/build/writeApiSignatures.d.ts +32 -27
  10. package/dist/build/writeApiSignatures.js +37 -42
  11. package/dist/build/writeFileAtomically.d.ts +8 -0
  12. package/dist/build/writeFileAtomically.js +22 -0
  13. package/dist/build.d.ts +8 -3
  14. package/dist/build.js +6 -3
  15. package/dist/client/LambderUploadRunner.d.ts +96 -0
  16. package/dist/client/LambderUploadRunner.js +234 -0
  17. package/dist/client.d.ts +4 -0
  18. package/dist/client.js +4 -0
  19. package/dist/core/Lambder.d.ts +9 -10
  20. package/dist/core/Lambder.js +9 -10
  21. package/dist/index.d.ts +11 -1
  22. package/dist/index.js +8 -0
  23. package/dist/mock/lambderMockMswHandler.d.ts +10 -4
  24. package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
  25. package/dist/mock/lambderMockUploadMswHandler.js +28 -0
  26. package/dist/mock.d.ts +3 -0
  27. package/dist/mock.js +4 -0
  28. package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
  29. package/dist/shared/contracts/LambderUploadBucket.js +74 -0
  30. package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
  31. package/dist/shared/util/LambderContentDisposition.js +13 -0
  32. package/dist/shared/util/LambderTextDigest.d.ts +7 -5
  33. package/dist/shared/util/LambderTextDigest.js +11 -5
  34. package/dist/shared/wire/LambderApiContract.d.ts +10 -40
  35. package/dist/shared/wire/LambderApiRefusal.d.ts +6 -0
  36. package/dist/shared/wire/LambderApiRefusal.js +6 -0
  37. package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
  38. package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
  39. package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
  40. package/dist/shared/wire/LambderUploadRefusal.js +18 -0
  41. package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
  42. package/dist/shared/wire/LambderUploadSchemas.js +30 -0
  43. package/dist/stores/LambderDdbSdk.js +1 -5
  44. package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
  45. package/dist/stores/LambderMemoryUploadBucket.js +219 -0
  46. package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
  47. package/dist/stores/LambderS3UploadBucket.js +144 -0
  48. package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
  49. package/dist/stores/LambderSdkInstallHint.js +14 -0
  50. package/dist/testing/LambderTestApp.d.ts +4 -4
  51. package/dist/testing.d.ts +2 -0
  52. package/dist/testing.js +1 -0
  53. 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 over text through WebCrypto, as hex: the one digest every layer
3
- * shares. The session crypto hashes bearer secrets with it and the
4
- * rate-limit engine folds an over-long tracker key with it, so both key
5
- * spaces are built from the same primitive on every runtime (browsers on a
6
- * secure context, Node 20+, edge runtimes).
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 over text through WebCrypto, as hex: the one digest every layer
4
- * shares. The session crypto hashes bearer secrets with it and the
5
- * rate-limit engine folds an over-long tracker key with it, so both key
6
- * spaces are built from the same primitive on every runtime (browsers on a
7
- * secure context, Node 20+, edge runtimes).
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
- * The contract as one object type, for the `export interface` a consuming
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
- * Chaining leaves the contract an intersection one member deep per endpoint
164
- * (LambderMergeContract above), and every `C[K]` against a type parameter
165
- * resolves the property across all of them. The reading helpers below are
166
- * built on that lookup, so each pays it again per endpoint: in a 182-endpoint
167
- * app one indexed access costs ~3,000 type instantiations and one mock
168
- * registration ~18,000.
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 LambderFlattenContract<C> = {
193
- [K in keyof C]: C[K];
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
+ }