1import { Bucket } from "@/Neon/Bucket";
2import { Credential } from "@/Neon/Credential";
3import { Project } from "@/Neon/Project";
4import { providers } from "@/Neon/Providers";
5import { storageLayer } from "@/Neon/Storage";
6import * as Test from "@/Test/Alchemy";
7import * as S3 from "@distilled.cloud/aws/s3";
8import * as SDK from "@distilled.cloud/neon";
9import { expect } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import * as Redacted from "effect/Redacted";
12import * as Result from "effect/Result";
13import * as Schedule from "effect/Schedule";
14import * as HttpClient from "effect/http/HttpClient";
15import { signStorageRead } from "./fixtures/StorageNative.ts";
16
17const { test } = Test.make({ providers: providers() });
18
19test.provider(
20 "read scopes authorize S3 and write-only rejection is typed",
21 (stack) =>
22 Effect.gen(function* () {
23 yield* stack.destroy();
24 const { read, write, both } = yield* stack.deploy(
25 Effect.gen(function* () {
26 const project = yield* Project("StorageCredentialProject", {
27 region: "aws-us-east-2",
28 });
29 const read = yield* Credential("Read", {
30 project,
31 scopes: ["storage:read"],
32 });
33 const write = yield* Credential("Write", {
34 project,
35 scopes: ["storage:write"],
36 });
37 const both = yield* Credential("Both", {
38 project,
39 scopes: ["storage:read", "storage:write"],
40 });
41 return { read, write, both };
42 }),
43 );
44 const storage = yield* SDK.getProjectBranchStorage({
45 project_id: read.projectId,
46 branch_id: read.branchId,
47 });
48 for (const credential of [read, write, both]) {
49 const response = yield* S3.listBuckets({}).pipe(
50 Effect.provide(
51 storageLayer({
52 endpoint: storage.s3_endpoint,
53 region: storage.region,
54 accessKeyId: credential.tokenId,
55 secretAccessKey: credential.s3SecretAccessKey,
56 }),
57 ),
58 Effect.retry({
59 while: (error) => error._tag === "AccessDeniedException",
60 schedule: Schedule.spaced("500 millis"),
61 times: 8,
62 }),
63 Effect.result,
64 );
65 const signed = yield* Effect.tryPromise(() =>
66 signStorageRead({
67 endpoint: storage.s3_endpoint,
68 region: storage.region,
69 accessKeyId: credential.tokenId,
70 secretAccessKey: Redacted.value(credential.s3SecretAccessKey),
71 }),
72 );
73 const native = yield* (yield* HttpClient.HttpClient).get(signed.url, {
74 headers: signed.headers,
75 });
76 yield* Effect.log(
77 `Neon scope ${credential.scopes.join(",")}: distilled=${Result.isSuccess(response) ? "ok" : response.failure._tag}; native=${native.status}`,
78 );
79 if (credential === write) {
80 expect(native.status).toBe(403);
81 expect(Result.isFailure(response)).toBe(true);
82 if (Result.isFailure(response))
83 expect(response.failure._tag).toBe("AccessDeniedException");
84 } else {
85 expect(native.status).toBe(200);
86 expect(Result.isSuccess(response)).toBe(true);
87 }
88 }
89 yield* stack.destroy();
90 yield* stack.destroy();
91 }),
92 {
93 tags: [
94 "provider:aws",
95 "provider:aws:s3",
96 "provider:neon",
97 "provider:neon:credential",
98 "provider:neon:project",
99 "live",
100 ],
101 timeout: 120_000,
102 },
103);
104
105// Neon's docs state storage:write includes all read operations, but the data
106// plane enforces write-only strictly: Put/Delete succeed while GetObject,
107// HeadObject, ListObjectsV2, and ListBuckets all return AccessDenied
108// (measured 2026-09-20; reported upstream). Managed write clients therefore
109// request storage:read alongside storage:write. This probe pins the observed
110// contract — it fails if Neon ships the documented implied read, at which
111// point write clients can drop the extra read scope.
112test.provider(
113 "storage:write-only is enforced strictly write-only (documented implied read is absent)",
114 (stack) =>
115 Effect.gen(function* () {
116 yield* stack.destroy();
117 const { bucket, credential } = yield* stack.deploy(
118 Effect.gen(function* () {
119 const project = yield* Project("WriteImpliesReadProject", {
120 region: "aws-us-east-2",
121 });
122 return {
123 bucket: yield* Bucket("Probe", {
124 project,
125 name: "write-only-probe",
126 }),
127 credential: yield* Credential("Writer", {
128 project,
129 scopes: ["storage:write"],
130 }),
131 };
132 }),
133 );
134 const storage = yield* SDK.getProjectBranchStorage({
135 project_id: credential.projectId,
136 branch_id: credential.branchId,
137 });
138 const layer = storageLayer({
139 endpoint: storage.s3_endpoint,
140 region: storage.region,
141 accessKeyId: credential.tokenId,
142 secretAccessKey: credential.s3SecretAccessKey,
143 });
144 const outcome = <A, E>(op: Effect.Effect<A, E, any>) =>
145 op.pipe(
146 Effect.provide(layer),
147 Effect.result,
148 Effect.map((result) =>
149 Result.isSuccess(result)
150 ? "ok"
151 : ((result.failure as { _tag: string })._tag ?? "unknown"),
152 ),
153 );
154 const matrix = {
155 PutObject: yield* outcome(
156 S3.putObject({
157 Bucket: bucket.bucketName,
158 Key: "probe.txt",
159 Body: new TextEncoder().encode("probe"),
160 }),
161 ),
162 GetObject: yield* outcome(
163 S3.getObject({ Bucket: bucket.bucketName, Key: "probe.txt" }),
164 ),
165 HeadObject: yield* outcome(
166 S3.headObject({ Bucket: bucket.bucketName, Key: "probe.txt" }),
167 ),
168 ListObjectsV2: yield* outcome(
169 S3.listObjectsV2({ Bucket: bucket.bucketName }),
170 ),
171 ListBuckets: yield* outcome(S3.listBuckets({})),
172 DeleteObject: yield* outcome(
173 S3.deleteObject({ Bucket: bucket.bucketName, Key: "probe.txt" }),
174 ),
175 };
176 yield* Effect.log(
177 `Neon storage:write-only matrix: ${JSON.stringify(matrix)}`,
178 );
179 yield* stack.destroy();
180 expect(
181 yield* SDK.getProject({ project_id: credential.projectId }).pipe(
182 Effect.as(false),
183 Effect.catchTag("NotFound", () => Effect.succeed(true)),
184 ),
185 ).toBe(true);
186 yield* Effect.log("Write-only storage fixture project is absent");
187 yield* stack.destroy();
188 expect(matrix).toEqual({
189 PutObject: "ok",
190 GetObject: "AccessDeniedException",
191 HeadObject: "AccessDeniedException",
192 ListObjectsV2: "AccessDeniedException",
193 ListBuckets: "AccessDeniedException",
194 DeleteObject: "ok",
195 });
196 }),
197 {
198 tags: [
199 "provider:aws",
200 "provider:aws:s3",
201 "provider:neon",
202 "provider:neon:bucket",
203 "provider:neon:credential",
204 "provider:neon:project",
205 "live",
206 ],
207 timeout: 120_000,
208 },
209);