@kutur-kit/assets 0.0.0-stage → 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +161 -2
  3. package/dist/Asset.d.ts +174 -0
  4. package/dist/Asset.d.ts.map +1 -0
  5. package/dist/Asset.js +156 -0
  6. package/dist/Asset.js.map +1 -0
  7. package/dist/AssetBucket.d.ts +73 -0
  8. package/dist/AssetBucket.d.ts.map +1 -0
  9. package/dist/AssetBucket.js +19 -0
  10. package/dist/AssetBucket.js.map +1 -0
  11. package/dist/AssetDelivery.d.ts +23 -0
  12. package/dist/AssetDelivery.d.ts.map +1 -0
  13. package/dist/AssetDelivery.js +12 -0
  14. package/dist/AssetDelivery.js.map +1 -0
  15. package/dist/AssetSigning.d.ts +109 -0
  16. package/dist/AssetSigning.d.ts.map +1 -0
  17. package/dist/AssetSigning.js +93 -0
  18. package/dist/AssetSigning.js.map +1 -0
  19. package/dist/alchemy/r2.d.ts +40 -0
  20. package/dist/alchemy/r2.d.ts.map +1 -0
  21. package/dist/alchemy/r2.js +84 -0
  22. package/dist/alchemy/r2.js.map +1 -0
  23. package/dist/delivery/local.d.ts +17 -0
  24. package/dist/delivery/local.d.ts.map +1 -0
  25. package/dist/delivery/local.js +20 -0
  26. package/dist/delivery/local.js.map +1 -0
  27. package/dist/filesystem.d.ts +15 -0
  28. package/dist/filesystem.d.ts.map +1 -0
  29. package/dist/filesystem.js +90 -0
  30. package/dist/filesystem.js.map +1 -0
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +3 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/internal/bytes.d.ts +3 -0
  36. package/dist/internal/bytes.d.ts.map +1 -0
  37. package/dist/internal/bytes.js +11 -0
  38. package/dist/internal/bytes.js.map +1 -0
  39. package/dist/internal/http.d.ts +17 -0
  40. package/dist/internal/http.d.ts.map +1 -0
  41. package/dist/internal/http.js +38 -0
  42. package/dist/internal/http.js.map +1 -0
  43. package/dist/internal/sigv4.d.ts +15 -0
  44. package/dist/internal/sigv4.d.ts.map +1 -0
  45. package/dist/internal/sigv4.js +52 -0
  46. package/dist/internal/sigv4.js.map +1 -0
  47. package/dist/internal/url.d.ts +3 -0
  48. package/dist/internal/url.d.ts.map +1 -0
  49. package/dist/internal/url.js +3 -0
  50. package/dist/internal/url.js.map +1 -0
  51. package/dist/memory.d.ts +11 -0
  52. package/dist/memory.d.ts.map +1 -0
  53. package/dist/memory.js +34 -0
  54. package/dist/memory.js.map +1 -0
  55. package/dist/s3.d.ts +42 -0
  56. package/dist/s3.d.ts.map +1 -0
  57. package/dist/s3.js +67 -0
  58. package/dist/s3.js.map +1 -0
  59. package/dist/signing/local.d.ts +27 -0
  60. package/dist/signing/local.d.ts.map +1 -0
  61. package/dist/signing/local.js +128 -0
  62. package/dist/signing/local.js.map +1 -0
  63. package/dist/testing/AssetBucketContract.d.ts +18 -0
  64. package/dist/testing/AssetBucketContract.d.ts.map +1 -0
  65. package/dist/testing/AssetBucketContract.js +124 -0
  66. package/dist/testing/AssetBucketContract.js.map +1 -0
  67. package/dist/testing/AssetSigningContract.d.ts +18 -0
  68. package/dist/testing/AssetSigningContract.d.ts.map +1 -0
  69. package/dist/testing/AssetSigningContract.js +132 -0
  70. package/dist/testing/AssetSigningContract.js.map +1 -0
  71. package/dist/testing/index.d.ts +3 -0
  72. package/dist/testing/index.d.ts.map +1 -0
  73. package/dist/testing/index.js +3 -0
  74. package/dist/testing/index.js.map +1 -0
  75. package/package.json +103 -3
  76. package/src/Asset.ts +443 -0
  77. package/src/AssetBucket.ts +82 -0
  78. package/src/AssetDelivery.ts +41 -0
  79. package/src/AssetSigning.ts +206 -0
  80. package/src/alchemy/r2.ts +151 -0
  81. package/src/delivery/local.ts +41 -0
  82. package/src/filesystem.ts +166 -0
  83. package/src/index.ts +3 -0
  84. package/src/internal/bytes.ts +12 -0
  85. package/src/internal/http.ts +54 -0
  86. package/src/internal/sigv4.ts +84 -0
  87. package/src/internal/url.ts +2 -0
  88. package/src/memory.ts +65 -0
  89. package/src/s3.ts +138 -0
  90. package/src/signing/local.ts +210 -0
  91. package/src/testing/AssetBucketContract.ts +215 -0
  92. package/src/testing/AssetSigningContract.ts +254 -0
  93. package/src/testing/index.ts +3 -0
@@ -0,0 +1,84 @@
1
+ import type * as AssetSigning from "../AssetSigning.ts";
2
+
3
+ import * as Credentials from "@distilled.cloud/aws/Credentials";
4
+ import * as Presign from "@distilled.cloud/aws/Presign";
5
+ import * as Region from "@distilled.cloud/aws/Region";
6
+ import { Clock, DateTime, Duration, Effect } from "effect";
7
+
8
+ import * as AssetBucket from "../AssetBucket.ts";
9
+ import { encodeKey } from "./url.ts";
10
+
11
+ /** Where signed URLs point: path-style, `${endpoint}/${bucket}/${key}`. */
12
+ export interface Target {
13
+ readonly endpoint: string;
14
+ readonly bucket: string;
15
+ }
16
+
17
+ // The longest S3 allows for a presigned URL.
18
+ const maxExpiry = Duration.days(7);
19
+
20
+ /**
21
+ * Signs with SigV4 for the S3 API. The target and the `Credentials` are
22
+ * resolved for each request, so stores that deliver them at runtime, such as
23
+ * alchemy's R2 bindings, can sign too.
24
+ */
25
+ export const make = <R>(
26
+ bucketId: string,
27
+ target: Effect.Effect<Target, never, R>,
28
+ ): Effect.Effect<AssetSigning.Signing, never, Credentials.Credentials | R> =>
29
+ Effect.map(Effect.context<Credentials.Credentials | R>(), (context) => {
30
+ const sign = (
31
+ method: "PUT" | "GET",
32
+ key: string,
33
+ expiresIn: Duration.Duration,
34
+ signed: Readonly<Record<string, string>>,
35
+ ) =>
36
+ Effect.gen(function* () {
37
+ const { endpoint, bucket } = yield* target;
38
+ const credentials = yield* yield* Credentials.Credentials;
39
+ const now = yield* Clock.currentTimeMillis;
40
+ const seconds = Math.ceil(Duration.toMillis(Duration.min(expiresIn, maxExpiry)) / 1000);
41
+
42
+ const url = yield* Presign.presignUrl({
43
+ method,
44
+ url: `${endpoint.replace(/\/+$/u, "")}/${bucket}/${encodeKey(key)}`,
45
+ service: "s3",
46
+ region: credentials.region,
47
+ expiresIn: seconds,
48
+ headers: signed,
49
+ // The signing time comes from Effect's clock, as the expiry does.
50
+ datetime: new Date(now).toISOString().replace(/[-:]|\.\d{3}/gu, ""),
51
+ }).pipe(Effect.provideService(Region.Region, Effect.succeed(credentials.region)));
52
+
53
+ return {
54
+ method,
55
+ url,
56
+ // Browsers set Content-Length from the body; the rest is sent as given.
57
+ headers: Object.fromEntries(
58
+ Object.entries(signed).filter(([name]) => name !== "content-length"),
59
+ ),
60
+ expiresAt: DateTime.makeUnsafe(now + seconds * 1000),
61
+ };
62
+ }).pipe(
63
+ Effect.provideContext(context),
64
+ Effect.mapError(
65
+ (cause) =>
66
+ new AssetBucket.AssetBucketError({ operation: "sign", bucket: bucketId, key, cause }),
67
+ ),
68
+ );
69
+
70
+ return {
71
+ // Content-Length is signed: without it, the storage accepts any size.
72
+ // `If-None-Match: *` makes the URL write-once: replayed after its
73
+ // upload, even with other bytes of the same length, it is refused.
74
+ signPut: ({ key, contentType, contentLength, cacheControl, expiresIn }) =>
75
+ sign("PUT", key, expiresIn, {
76
+ "content-type": contentType,
77
+ "content-length": String(contentLength),
78
+ "if-none-match": "*",
79
+ // Signed, or browsers' `no-cache` for conditional requests is stored.
80
+ "cache-control": cacheControl,
81
+ }),
82
+ signGet: ({ key, expiresIn }) => sign("GET", key, expiresIn, {}),
83
+ };
84
+ });
@@ -0,0 +1,2 @@
1
+ /** A key in a URL path: segments encoded, slashes kept. */
2
+ export const encodeKey = (key: string) => key.split("/").map(encodeURIComponent).join("/");
package/src/memory.ts ADDED
@@ -0,0 +1,65 @@
1
+ import type * as Asset from "./Asset.ts";
2
+ import type * as AssetBucket from "./AssetBucket.ts";
3
+
4
+ import { ByteSize, Effect, Layer, Option, Stream } from "effect";
5
+
6
+ import { concat } from "./internal/bytes.ts";
7
+
8
+ interface Entry {
9
+ readonly info: AssetBucket.Info;
10
+ readonly bytes: Uint8Array;
11
+ }
12
+
13
+ /**
14
+ * Backs a bucket with memory for the lifetime of the layer. Bodies are copied
15
+ * in and out, so callers cannot change stored bytes.
16
+ */
17
+ export const layer = <Self, Id extends string>(
18
+ bucket: AssetBucket.Tag<Self, Id>,
19
+ ): Layer.Layer<Self> =>
20
+ Layer.sync(bucket, () => {
21
+ const objects = new Map<string, Entry>();
22
+
23
+ return bucket.of({
24
+ put: ({ key, contentType, cacheControl, body }) =>
25
+ Stream.runCollect(body).pipe(
26
+ Effect.map((chunks) => {
27
+ const bytes = concat(chunks);
28
+ const info = { size: ByteSize.bytes(bytes.length), contentType, cacheControl };
29
+
30
+ objects.set(key, { info, bytes });
31
+
32
+ return info;
33
+ }),
34
+ ),
35
+ get: (key) =>
36
+ Effect.sync(() =>
37
+ Option.map(Option.fromUndefinedOr(objects.get(key)), ({ info, bytes }) => ({
38
+ info,
39
+ body: Stream.succeed(bytes.slice()),
40
+ })),
41
+ ),
42
+ head: (key) =>
43
+ Effect.sync(() => Option.map(Option.fromUndefinedOr(objects.get(key)), ({ info }) => info)),
44
+ delete: (key) =>
45
+ Effect.sync(() => {
46
+ objects.delete(key);
47
+ }),
48
+ });
49
+ });
50
+
51
+ /** Backs the buckets of the given assets with memory, one store per bucket. */
52
+ export const toLayer = <const Assets extends ReadonlyArray<Asset.Any>>(
53
+ assets: Assets,
54
+ ): Layer.Layer<Asset.BucketOf<Assets[number]>> => {
55
+ // SAFETY: asset buckets are tags from AssetBucket.Service.
56
+ const buckets = new Set(assets.map((asset) => asset.bucket as AssetBucket.Tag<unknown>));
57
+
58
+ const merged = [...buckets].reduce<Layer.Layer<never>>(
59
+ (layers, bucket) => Layer.merge(layers, layer(bucket)),
60
+ Layer.empty,
61
+ );
62
+
63
+ // SAFETY: the merged layers provide exactly the buckets of the listed assets.
64
+ return merged as Layer.Layer<Asset.BucketOf<Assets[number]>>;
65
+ };
package/src/s3.ts ADDED
@@ -0,0 +1,138 @@
1
+ import type * as AssetSigning from "./AssetSigning.ts";
2
+ import type * as Credentials from "@distilled.cloud/aws/Credentials";
3
+ import type * as HttpClient from "effect/http/HttpClient";
4
+
5
+ import * as S3 from "@distilled.cloud/aws/s3";
6
+ import { ByteSize, Effect, Layer, Option, Stream } from "effect";
7
+
8
+ import * as AssetBucket from "./AssetBucket.ts";
9
+ import * as AssetSigningModule from "./AssetSigning.ts";
10
+ import { concat } from "./internal/bytes.ts";
11
+ import * as SigV4 from "./internal/sigv4.ts";
12
+
13
+ export interface Options {
14
+ /** The bucket's name in the storage. */
15
+ readonly bucket: string;
16
+ }
17
+
18
+ export interface SigningOptions extends Options {
19
+ /**
20
+ * Where clients reach the storage, such as
21
+ * `https://<account>.r2.cloudflarestorage.com` or `http://localhost:9000`.
22
+ * Signed URLs are path-style under it: `${endpoint}/${bucket}/${key}`.
23
+ */
24
+ readonly endpoint: string;
25
+ }
26
+
27
+ type Requirements = Credentials.Credentials | HttpClient.HttpClient;
28
+
29
+ const failure =
30
+ (bucket: string, operation: AssetBucket.AssetBucketError["operation"], key: string) =>
31
+ (cause: unknown) =>
32
+ new AssetBucket.AssetBucketError({ operation, bucket, key, cause });
33
+
34
+ /**
35
+ * Backs a bucket with S3 or a store with its API, such as R2 or RustFS,
36
+ * through `@distilled.cloud/aws`. Provide its `Credentials` and an
37
+ * `HttpClient`, and an `Endpoint` for stores other than AWS.
38
+ *
39
+ * Bodies are collected before they are sent, so a failing body sends nothing.
40
+ * Uploads through the app are small by `maxSize`; larger files go to storage
41
+ * directly, through `signing`.
42
+ */
43
+ export const layer = <Self, Id extends string>(
44
+ bucket: AssetBucket.Tag<Self, Id>,
45
+ options: Options,
46
+ ): Layer.Layer<Self, never, Requirements> =>
47
+ Layer.effect(
48
+ bucket,
49
+ Effect.gen(function* () {
50
+ // S3 reads the optional `Endpoint` and `Region` when called, so the
51
+ // whole context is kept, not just the required services.
52
+ const context = yield* Effect.context<Requirements>();
53
+ const Bucket = options.bucket;
54
+
55
+ const fail = (operation: AssetBucket.AssetBucketError["operation"], key: string) =>
56
+ failure(bucket.id, operation, key);
57
+
58
+ return bucket.of({
59
+ put: ({ key, contentType, cacheControl, body }) =>
60
+ Effect.gen(function* () {
61
+ const bytes = concat(yield* Stream.runCollect(body));
62
+
63
+ const request: S3.PutObjectRequest = {
64
+ Bucket,
65
+ Key: key,
66
+ Body: bytes,
67
+ ContentType: contentType,
68
+ };
69
+
70
+ if (cacheControl !== undefined) request.CacheControl = cacheControl;
71
+
72
+ yield* S3.putObject(request).pipe(
73
+ Effect.provideContext(context),
74
+ Effect.mapError(fail("put", key)),
75
+ );
76
+
77
+ return { size: ByteSize.bytes(bytes.length), contentType, cacheControl };
78
+ }),
79
+ get: (key) =>
80
+ S3.getObject({ Bucket, Key: key }).pipe(
81
+ Effect.map((object) =>
82
+ Option.some({
83
+ info: {
84
+ size: ByteSize.bytes(object.ContentLength ?? 0),
85
+ contentType: object.ContentType ?? "application/octet-stream",
86
+ cacheControl: object.CacheControl,
87
+ },
88
+ body:
89
+ object.Body === undefined
90
+ ? Stream.empty
91
+ : Stream.mapError(object.Body, fail("get", key)),
92
+ }),
93
+ ),
94
+ Effect.catchTag("NoSuchKey", () => Effect.succeedNone),
95
+ Effect.provideContext(context),
96
+ Effect.mapError(fail("get", key)),
97
+ ),
98
+ head: (key) =>
99
+ S3.headObject({ Bucket, Key: key }).pipe(
100
+ Effect.map((object) =>
101
+ Option.some({
102
+ size: ByteSize.bytes(object.ContentLength ?? 0),
103
+ contentType: object.ContentType ?? "application/octet-stream",
104
+ cacheControl: object.CacheControl,
105
+ }),
106
+ ),
107
+ Effect.catchTag("NotFound", () => Effect.succeedNone),
108
+ Effect.provideContext(context),
109
+ Effect.mapError(fail("head", key)),
110
+ ),
111
+ delete: (key) =>
112
+ S3.deleteObject({ Bucket, Key: key }).pipe(
113
+ Effect.asVoid,
114
+ Effect.provideContext(context),
115
+ Effect.mapError(fail("delete", key)),
116
+ ),
117
+ });
118
+ }),
119
+ );
120
+
121
+ /**
122
+ * Signs URLs for a bucket with SigV4, for clients to upload to and download
123
+ * from the storage directly. Uploads are signed with their content type and
124
+ * length, the cache control to store, and to create an object only, which
125
+ * the storage enforces.
126
+ */
127
+ export const signing = <Self, Id extends string>(
128
+ bucket: AssetBucket.Tag<Self, Id>,
129
+ options: SigningOptions,
130
+ ): Layer.Layer<AssetSigning.SigningOf<Self>, never, Credentials.Credentials> =>
131
+ Layer.effect(AssetSigningModule.of(bucket), SigV4.make(bucket.id, Effect.succeed(options)));
132
+
133
+ /**
134
+ * R2's S3 endpoint for an account, for `signing` and an S3 `Endpoint`.
135
+ * Requests to it are signed for the region `auto`.
136
+ */
137
+ export const r2Endpoint = (accountId: string, jurisdiction?: "eu" | "fedramp") =>
138
+ `https://${accountId}.${jurisdiction === undefined ? "" : `${jurisdiction}.`}r2.cloudflarestorage.com`;
@@ -0,0 +1,210 @@
1
+ import type * as AssetBucket from "../AssetBucket.ts";
2
+
3
+ import {
4
+ ByteSize,
5
+ Clock,
6
+ DateTime,
7
+ Duration,
8
+ Effect,
9
+ Layer,
10
+ Option,
11
+ Redacted,
12
+ Stream,
13
+ } from "effect";
14
+ import { HttpRouter, HttpServerResponse } from "effect/http";
15
+
16
+ import * as AssetSigning from "../AssetSigning.ts";
17
+ import { mount, respond, target } from "../internal/http.ts";
18
+ import { encodeKey } from "../internal/url.ts";
19
+
20
+ export interface Options {
21
+ /**
22
+ * Where `routes` are mounted, such as `/storage/private` or
23
+ * `http://localhost:3000/storage/private`. Signed URLs start with it.
24
+ */
25
+ readonly baseUrl: string;
26
+ /** Signs URLs. Changing it invalidates URLs already handed out. */
27
+ readonly secret: Redacted.Redacted<string>;
28
+ }
29
+
30
+ interface Signed {
31
+ readonly method: "PUT" | "GET";
32
+ readonly key: string;
33
+ readonly expires: number;
34
+ readonly contentType?: string | undefined;
35
+ readonly contentLength?: string | undefined;
36
+ /** `*` on uploads: they create an object and never replace one. */
37
+ readonly ifNoneMatch?: string | undefined;
38
+ readonly cacheControl?: string | undefined;
39
+ }
40
+
41
+ const hex = (bytes: ArrayBuffer) =>
42
+ Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
43
+
44
+ // The signature covers everything a client must not change.
45
+ const sign = (secret: Redacted.Redacted<string>, signed: Signed) =>
46
+ Effect.promise(async () => {
47
+ const key = await crypto.subtle.importKey(
48
+ "raw",
49
+ new TextEncoder().encode(Redacted.value(secret)),
50
+ { name: "HMAC", hash: "SHA-256" },
51
+ false,
52
+ ["sign"],
53
+ );
54
+
55
+ const message = [
56
+ signed.method,
57
+ signed.key,
58
+ signed.expires,
59
+ signed.contentType ?? "",
60
+ signed.contentLength ?? "",
61
+ signed.ifNoneMatch ?? "",
62
+ signed.cacheControl ?? "",
63
+ ].join("\n");
64
+
65
+ return hex(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message)));
66
+ });
67
+
68
+ // Compares in time independent of where the strings differ.
69
+ const equal = (a: string, b: string) => {
70
+ let difference = a.length ^ b.length;
71
+
72
+ for (let index = 0; index < Math.max(a.length, b.length); index++)
73
+ difference |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0);
74
+
75
+ return difference === 0;
76
+ };
77
+
78
+ /**
79
+ * Signs URLs for a bucket with an HMAC, for development, tests or a single
80
+ * self-hosted server. `routes` verifies them, so storage needs no signing.
81
+ */
82
+ export const layer = <Self, Id extends string>(
83
+ bucket: AssetBucket.Tag<Self, Id>,
84
+ options: Options,
85
+ ): Layer.Layer<AssetSigning.SigningOf<Self>> => {
86
+ const request = (signed: Omit<Signed, "expires">, expiresIn: Duration.Duration) =>
87
+ Effect.gen(function* () {
88
+ const now = yield* Clock.currentTimeMillis;
89
+ const expires = Math.ceil((now + Duration.toMillis(expiresIn)) / 1000);
90
+ const signature = yield* sign(options.secret, { ...signed, expires });
91
+ const query = new URLSearchParams({ expires: String(expires), signature });
92
+ const headers: Record<string, string> = {};
93
+
94
+ if (signed.contentType !== undefined) headers["content-type"] = signed.contentType;
95
+
96
+ if (signed.ifNoneMatch !== undefined) headers["if-none-match"] = signed.ifNoneMatch;
97
+
98
+ if (signed.cacheControl !== undefined) headers["cache-control"] = signed.cacheControl;
99
+
100
+ return {
101
+ method: signed.method,
102
+ url: `${options.baseUrl}/${encodeKey(signed.key)}?${query.toString()}`,
103
+ headers,
104
+ expiresAt: DateTime.makeUnsafe(expires * 1000),
105
+ };
106
+ });
107
+
108
+ return Layer.succeed(AssetSigning.of(bucket), {
109
+ signPut: ({ key, contentType, contentLength, cacheControl, expiresIn }) =>
110
+ request(
111
+ {
112
+ method: "PUT",
113
+ key,
114
+ contentType,
115
+ contentLength: String(contentLength),
116
+ ifNoneMatch: "*",
117
+ cacheControl,
118
+ },
119
+ expiresIn,
120
+ ),
121
+ signGet: ({ key, expiresIn }) => request({ method: "GET", key }, expiresIn),
122
+ });
123
+ };
124
+
125
+ const refused = HttpServerResponse.text("Forbidden", { status: 403 });
126
+
127
+ /**
128
+ * Accepts signed uploads and serves signed downloads of a bucket under
129
+ * `baseUrl`. Refuses a request that differs from what was signed: another
130
+ * method, key, content type, length or cache control, or one sent after it
131
+ * expires. An upload to a key that already holds an object is refused with
132
+ * 412.
133
+ */
134
+ export const routes = <Self, Id extends string>(
135
+ bucket: AssetBucket.Tag<Self, Id>,
136
+ options: Options,
137
+ ) => {
138
+ const { prefix, path } = mount(options.baseUrl);
139
+
140
+ const verify = (query: URLSearchParams, signed: Omit<Signed, "expires">) =>
141
+ Effect.gen(function* () {
142
+ const expires = Number(query.get("expires"));
143
+ const signature = query.get("signature") ?? "";
144
+ const now = yield* Clock.currentTimeMillis;
145
+ const expected = yield* sign(options.secret, { ...signed, expires });
146
+
147
+ return now <= expires * 1000 && equal(signature, expected);
148
+ });
149
+
150
+ const upload = Effect.gen(function* () {
151
+ const { request, key, query } = yield* target(prefix);
152
+ const contentType = request.headers["content-type"];
153
+ const contentLength = request.headers["content-length"];
154
+ const ifNoneMatch = request.headers["if-none-match"];
155
+ const cacheControl = request.headers["cache-control"];
156
+
157
+ // The signed length is required, so the body's is checked against it.
158
+ const length = Option.flatMap(Option.fromUndefinedOr(contentLength), (header) =>
159
+ ByteSize.fromInput(Number(header)),
160
+ );
161
+
162
+ const allowed =
163
+ Option.isSome(key) &&
164
+ Option.isSome(length) &&
165
+ (yield* verify(query, {
166
+ method: "PUT",
167
+ key: key.value,
168
+ contentType,
169
+ contentLength,
170
+ ifNoneMatch,
171
+ cacheControl,
172
+ }));
173
+
174
+ if (!allowed || Option.isNone(key) || Option.isNone(length)) return refused;
175
+
176
+ const store = yield* bucket;
177
+
178
+ // A signed upload writes once, as S3 answers `If-None-Match: *`. Checked
179
+ // before writing, so two uploads racing to one key may both succeed: these
180
+ // routes serve development and tests.
181
+ const existing = yield* store.head(key.value);
182
+
183
+ if (Option.isSome(existing))
184
+ return HttpServerResponse.text("Precondition Failed", { status: 412 });
185
+
186
+ // Node ends the body at its Content-Length; a client that sends less
187
+ // fails the stream, so the bucket keeps what it had.
188
+ yield* store.put({
189
+ key: key.value,
190
+ contentType: contentType ?? "",
191
+ cacheControl,
192
+ body: Stream.orDie(request.stream),
193
+ contentLength: length.value,
194
+ });
195
+
196
+ return HttpServerResponse.empty({ status: 200 });
197
+ }).pipe(Effect.orDie);
198
+
199
+ const download = Effect.gen(function* () {
200
+ const { key, query } = yield* target(prefix);
201
+
202
+ const allowed = Option.isSome(key) && (yield* verify(query, { method: "GET", key: key.value }));
203
+
204
+ if (!allowed || Option.isNone(key)) return refused;
205
+
206
+ return yield* respond(yield* bucket, key.value);
207
+ }).pipe(Effect.orDie);
208
+
209
+ return Layer.mergeAll(HttpRouter.add("PUT", path, upload), HttpRouter.add("GET", path, download));
210
+ };