@reventlessdev/reventless-aws 3.0.0-alpha.250 → 3.0.0-alpha.252

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.
@@ -12,25 +12,11 @@ function getEnv(k) {
12
12
  }
13
13
  }
14
14
 
15
- function corsHeaders() {
16
- return Object.fromEntries([
17
- [
15
+ function jsonHeaders() {
16
+ return Object.fromEntries([[
18
17
  "content-type",
19
18
  "application/json"
20
- ],
21
- [
22
- "access-control-allow-origin",
23
- "*"
24
- ],
25
- [
26
- "access-control-allow-methods",
27
- "GET,OPTIONS"
28
- ],
29
- [
30
- "access-control-allow-headers",
31
- "*"
32
- ]
33
- ]);
19
+ ]]);
34
20
  }
35
21
 
36
22
  function readQueryParam(event) {
@@ -59,7 +45,10 @@ async function handler(event) {
59
45
  if (indexName === "" || q === "") {
60
46
  return {
61
47
  statusCode: 200,
62
- headers: corsHeaders(),
48
+ headers: Object.fromEntries([[
49
+ "content-type",
50
+ "application/json"
51
+ ]]),
63
52
  body: "[]"
64
53
  };
65
54
  }
@@ -101,7 +90,10 @@ async function handler(event) {
101
90
  });
102
91
  return {
103
92
  statusCode: 200,
104
- headers: corsHeaders(),
93
+ headers: Object.fromEntries([[
94
+ "content-type",
95
+ "application/json"
96
+ ]]),
105
97
  body: JSON.stringify(results)
106
98
  };
107
99
  } catch (raw_exn) {
@@ -109,7 +101,10 @@ async function handler(event) {
109
101
  console.error("Geocoder: search failed", exn);
110
102
  return {
111
103
  statusCode: 200,
112
- headers: corsHeaders(),
104
+ headers: Object.fromEntries([[
105
+ "content-type",
106
+ "application/json"
107
+ ]]),
113
108
  body: "[]"
114
109
  };
115
110
  }
@@ -117,7 +112,7 @@ async function handler(event) {
117
112
 
118
113
  export {
119
114
  getEnv,
120
- corsHeaders,
115
+ jsonHeaders,
121
116
  readQueryParam,
122
117
  handler,
123
118
  }
@@ -260,9 +260,9 @@ let buildInboundReceiver = (
260
260
  let result = await callback.receive(publishJsons, args)
261
261
  switch auditQueryDbOps {
262
262
  | Some(ops) =>
263
- let rows = callback.auditLog->Dict.toArray
264
- let _ = await rows->Array.reduce(Promise.resolve(), async (prev, (id, row)) => {
265
- let _ = await prev
263
+ let id = result->ReventlessCore.InboundTranslationSlice_Callback.requestIdOf
264
+ switch callback.auditLog->ReventlessCore.InboundTranslationSlice_Callback.takeAuditRow(id) {
265
+ | Some(row) =>
266
266
  let json = row->S.reverseConvertToJsonOrThrow(
267
267
  ReventlessCore.InboundTranslationSlice_Callback.auditRowSchema,
268
268
  )
@@ -291,7 +291,8 @@ let buildInboundReceiver = (
291
291
  `failed to persist audit row ${id}: ${detail}`,
292
292
  )->Effect.runSync
293
293
  }
294
- })
294
+ | None => ()
295
+ }
295
296
  | None => ()
296
297
  }
297
298
  result
@@ -1,7 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as S from "sury/src/S.res.mjs";
4
- import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
4
  import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
6
5
  import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
7
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
@@ -107,24 +106,21 @@ function buildInboundReceiver(spec, translation, publishJsons, auditQueryDbOps)
107
106
  return async args => {
108
107
  let result = await callback.receive(publishJsons, args);
109
108
  if (auditQueryDbOps !== undefined) {
110
- let rows = Object.entries(callback.auditLog);
111
- await Stdlib_Array.reduce(rows, Promise.resolve(), async (prev, param) => {
112
- let id = param[0];
113
- await prev;
114
- let json = S.reverseConvertToJsonOrThrow(param[1], InboundTranslationSlice_Callback$ReventlessCore.auditRowSchema);
109
+ let id = InboundTranslationSlice_Callback$ReventlessCore.requestIdOf(result);
110
+ let row = InboundTranslationSlice_Callback$ReventlessCore.takeAuditRow(callback.auditLog, id);
111
+ if (row !== undefined) {
112
+ let json = S.reverseConvertToJsonOrThrow(row, InboundTranslationSlice_Callback$ReventlessCore.auditRowSchema);
115
113
  try {
116
114
  let err = await auditQueryDbOps.save(id, json, "Overwrite", undefined);
117
- if (err.TAG === "Ok") {
118
- return;
119
- } else {
120
- return Effect.runSync(EffectLogger$ReventlessCore.logError("InboundTranslationSlice.audit", undefined, `failed to persist audit row ` + id + `: ` + QueryDb$ReventlessCore.storageErrorToString(err._0)));
115
+ if (err.TAG !== "Ok") {
116
+ Effect.runSync(EffectLogger$ReventlessCore.logError("InboundTranslationSlice.audit", undefined, `failed to persist audit row ` + id + `: ` + QueryDb$ReventlessCore.storageErrorToString(err._0)));
121
117
  }
122
118
  } catch (raw_exn) {
123
119
  let exn = Primitive_exceptions.internalToException(raw_exn);
124
120
  let detail = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "unknown error");
125
- return Effect.runSync(EffectLogger$ReventlessCore.logError("InboundTranslationSlice.audit", undefined, `failed to persist audit row ` + id + `: ` + detail));
121
+ Effect.runSync(EffectLogger$ReventlessCore.logError("InboundTranslationSlice.audit", undefined, `failed to persist audit row ` + id + `: ` + detail));
126
122
  }
127
- });
123
+ }
128
124
  }
129
125
  return CommandTopic_Helpers$ReventlessCore.commandOutcomeToJson(InboundTranslationSlice_Callback$ReventlessCore.receiveResultToOutcome(result));
130
126
  };
@@ -1,93 +1,119 @@
1
- // Direct-to-S3 upload presign service behind a public Lambda Function URL.
1
+ // Direct-to-S3 upload service, mounted on the platform GraphQL API.
2
2
  //
3
- // Deploy-time only: `make` provisions a compiled-EntryPoint Lambda (a plain
3
+ // Deploy-time only: `make` provisions one compiled-EntryPoint Lambda (a plain
4
4
  // `Lambda.Function` whose code archive re-exports `handler` from the compiled,
5
5
  // type-checked runtime module `Upload_Presign_S3_Ops` and ships the shared ESM
6
6
  // resolve-hook loader), an IAM execution role scoped to CloudWatch Logs +
7
- // `s3:PutObject` on the target bucket, and a Function URL (no auth) so a browser
8
- // can request a presigned PUT.
7
+ // `s3:PutObject`/`s3:DeleteObject`/`s3:GetObject` on the declared stores' prefixes,
8
+ // an AppSync Lambda data source, and two resolvers — `Mutation.Upload_Presign` and
9
+ // `Mutation.Upload_Release` — on the platform API.
9
10
  //
10
- // Why an EntryPoint and not a Pulumi `CallbackFunction`: the handler needs the
11
- // AWS SDK v3 S3 presigner. A serialized closure bakes the deploy machine's
12
- // version-specific SDK internals into the archive but then resolves `@smithy/*`
13
- // and `@aws-sdk/*` transitives from independently-versioned layer/runtime sources
14
- // that disagree at cold start (an inlined `client-s3` against a newer
15
- // `@smithy/smithy-client` base whose constructor no longer sets `middlewareStack`
16
- // `new S3Client()` throws). Shipping the compiled `_Ops` module with bare
17
- // `@aws-sdk/*` imports, resolved through the resolve-hook (`@aws-sdk/*` from the
18
- // runtime, `@smithy/*`/`@reventlessdev/*` from the layer), loads one internally
19
- // consistent SDK instead. The runtime logic lives in [Upload_Presign_S3_Ops.res].
11
+ // There is no Function URL and no anonymous surface: authentication is the platform
12
+ // API's Cognito authorizer, and the verified caller identity reaches the handler in
13
+ // the resolver payload (see the JS resolver code below). This is route B of
14
+ // [docs/plans/upload-release-path.md]; the anonymous Function URL and the unverified
15
+ // `decodeJwtSub` the mint side used to carry are gone.
16
+ //
17
+ // Why an EntryPoint and not a Pulumi `CallbackFunction`: the handler needs the AWS
18
+ // SDK v3 S3 presigner. A serialized closure bakes the deploy machine's
19
+ // version-specific SDK internals into the archive but then resolves `@smithy/*` and
20
+ // `@aws-sdk/*` transitives from independently-versioned layer/runtime sources that
21
+ // disagree at cold start. Shipping the compiled `_Ops` module with bare `@aws-sdk/*`
22
+ // imports, resolved through the resolve-hook, loads one internally consistent SDK.
23
+ // The runtime logic lives in [Upload_Presign_S3_Ops.res].
20
24
 
21
25
  open PulumiAws
22
26
 
23
- type serviceOutputs = {
24
- url: Pulumi.Output.t<string>,
25
- resources: array<Pulumi.Output.t<string>>,
27
+ type serviceOutputs = {resources: array<Pulumi.Output.t<string>>}
28
+
29
+ // A store the service can presign into and release from. `qualified` is the
30
+ // `{plugin}.{store}` name the caller passes as the mutation's `store` argument;
31
+ // `bucketName` is the physical bucket; `servedPrefix` is the prefix keys are rooted
32
+ // at (and therefore the prefix IAM is scoped to).
33
+ type uploadStore = {
34
+ qualified: string,
35
+ bucketName: Pulumi.Input.t<string>,
36
+ servedPrefix: string,
26
37
  }
27
38
 
28
39
  /**
29
- The prefix presigned object keys are rooted at, and therefore the prefix the
30
- store must be served under for the returned `/{key}` ref to resolve.
31
-
32
- Exported so the serve side reads the same constant the mint side writes. The two
33
- have to agree exactly: a mismatch deploys green and 404s every object, because
34
- nothing at deploy time compares a minted key against a cache behavior.
40
+ The prefix presigned object keys are rooted at, and therefore the prefix the store
41
+ must be served under for the returned `/{key}` ref to resolve. Exported so the serve
42
+ side reads the same constant the mint side writes.
35
43
  */
36
44
  let defaultServedPrefix = "uploads"
37
45
 
46
+ // JS resolver code (APPSYNC_JS runtime): pass the caller's arguments and the
47
+ // authorizer-verified identity to the Lambda, tagging the operation so one Lambda
48
+ // serves both mutations. CORS and auth belong to the API, not to this code.
49
+ let invokeCode = (operation: string): Pulumi.Input.t<string> =>
50
+ `import { util } from '@aws-appsync/utils';
51
+ export function request(ctx) {
52
+ const id = ctx.identity;
53
+ return {
54
+ operation: 'Invoke',
55
+ payload: {
56
+ operation: '${operation}',
57
+ arguments: ctx.args,
58
+ identity: id != null && id.sub != null
59
+ ? { sub: id.sub, username: id.username, claims: id.claims }
60
+ : null
61
+ }
62
+ };
63
+ }
64
+ export function response(ctx) {
65
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
66
+ return ctx.result;
67
+ }
68
+ `->Pulumi.Input.make
69
+
38
70
  let make = (
39
- ~bucketName: Pulumi.Input.t<string>,
40
- ~corsOrigins: array<string>=["*"],
41
- // Prefix the presigned object keys are rooted at; must match the served
42
- // bucket's CloudFront `{prefix}/*` behavior so the returned `/{key}` ref
43
- // resolves.
44
- ~servedPrefix: string=defaultServedPrefix,
45
- // Resource-name stem. Defaults to the single-service name this adapter had
46
- // when a deployment could only have one store, so the existing service keeps
47
- // its identity; declared stores each pass their own, because there is one
48
- // service per store.
49
- //
50
- // One service per store rather than one service with wildcard write across
51
- // stores: a single wide-scoped presigner reintroduces exactly the blast radius
52
- // the per-store split exists to remove, and it does so invisibly — every
53
- // upload still works.
54
- ~name: string="UploadPresignService",
55
- ~opts=?,
71
+ ~api: Pulumi.Output.t<AppSync.GraphQLApi.t>,
72
+ ~stores: array<uploadStore>,
73
+ ~releaseWindowSeconds: int=900,
74
+ ~name: string="UploadService",
75
+ ~opts: Pulumi.ComponentResource.options,
56
76
  ): serviceOutputs => {
57
- let serviceName = name
58
- let opts =
59
- opts->Option.map(ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions)
77
+ let opts = opts->ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions
60
78
 
61
79
  let lambdaRole = IAM.Role.makeWithDefaultPolicy(
62
- ~name=serviceName,
80
+ ~name=name ++ "Lambda",
63
81
  ~servicePrincipal=AWS.Lambda.principal->Pulumi.Output.make,
64
82
  ~tags=AWS.Tags.make(
65
- ~name=serviceName,
83
+ ~name=name ++ "Lambda",
66
84
  ~kind=ReventlessCore.ComponentType.Platform,
67
85
  ~role=Identity,
68
86
  ~scope=Platform,
69
87
  ),
70
- ~opts?,
88
+ ~opts,
71
89
  )
72
90
 
73
- // CloudWatch Logs (so failures are observable) plus least-privilege
74
- // `s3:PutObject` on this store's own keys.
91
+ // Resolve every store's physical bucket name once; both the env map the handler
92
+ // reads and the IAM the presign/release grant needs are built from it.
93
+ let resolvedStores =
94
+ stores
95
+ ->Array.map(s =>
96
+ s.bucketName
97
+ ->Pulumi.Output.fromInput
98
+ ->Pulumi.Output.apply(b => (s.qualified, b, s.servedPrefix))
99
+ )
100
+ ->Pulumi.Output.all
101
+
102
+ // CloudWatch Logs plus least-privilege S3 on each declared store's own keys.
75
103
  //
76
- // Scoped to `{bucket}/{servedPrefix}/*`, not to the whole bucket. Because keys
77
- // are rooted at the store's prefix in *both* layouts, this one expression is
78
- // least-privilege in both: a dedicated bucket's service still cannot write
79
- // outside its prefix, and a shared bucket's services cannot write into each
80
- // other's stores. The layouts stay one model even the policy does not fork.
104
+ // Scoped to `{bucket}/{servedPrefix}/*` per store, never the whole bucket:
105
+ // `PutObject` presigns the upload, `DeleteObject` performs the release, and
106
+ // `GetObject` backs the `HeadObject` the age check reads. One Lambda covers every
107
+ // declared store (route B1) but its reach is still the union of those prefixes,
108
+ // not a wildcard, so it cannot touch a key outside a declared store.
81
109
  let _policy =
82
- bucketName
83
- ->Pulumi.Output.fromInput
84
- ->Pulumi.Output.apply(b => {
85
- let arn = `arn:aws:s3:::${b}/${servedPrefix}/*`
110
+ resolvedStores->Pulumi.Output.apply(list => {
111
+ let arns = list->Array.map(((_, bucket, prefix)) => `arn:aws:s3:::${bucket}/${prefix}/*`)
86
112
  let _ = IAM.RolePolicy.make(
87
- ~name=`${serviceName}Policy`,
113
+ ~name=name ++ "LambdaPolicy",
88
114
  ~args={
89
115
  policy: PolicyDocument.make(
90
- ~id=`${serviceName}Policy`,
116
+ ~id=name ++ "LambdaPolicy",
91
117
  ~statements=[
92
118
  {
93
119
  sid: "AllowLambdaLogging",
@@ -96,10 +122,10 @@ let make = (
96
122
  resources: Resource("arn:aws:logs:*:*:*"),
97
123
  },
98
124
  {
99
- sid: "AllowUploadPut",
125
+ sid: "AllowUploadObjectAccess",
100
126
  effect: Allow,
101
- actions: Action("s3:PutObject"),
102
- resources: Resource(arn),
127
+ actions: Actions(["s3:PutObject", "s3:DeleteObject", "s3:GetObject"]),
128
+ resources: Resources(arns),
103
129
  },
104
130
  ],
105
131
  )
@@ -107,10 +133,27 @@ let make = (
107
133
  ->Pulumi.Input.make,
108
134
  role: lambdaRole.id->Pulumi.Output.asInput,
109
135
  },
110
- ~opts?,
136
+ ~opts,
111
137
  )
112
138
  })
113
139
 
140
+ // `UPLOAD_STORES` — the qualified-name → {bucket, prefix} map the handler resolves
141
+ // the caller's `store` argument against.
142
+ let uploadStoresJson =
143
+ resolvedStores->Pulumi.Output.apply(list =>
144
+ list
145
+ ->Array.map(((qualified, bucket, prefix)) => (
146
+ qualified,
147
+ Dict.fromArray([
148
+ ("bucket", JSON.Encode.string(bucket)),
149
+ ("prefix", JSON.Encode.string(prefix)),
150
+ ])->JSON.Encode.object,
151
+ ))
152
+ ->Dict.fromArray
153
+ ->JSON.Encode.object
154
+ ->JSON.stringify
155
+ )
156
+
114
157
  // Bundle reventless-aws (the compiled `_Ops` handler lives inside it) and
115
158
  // re-export its `handler`; buildCodeArchive also ships the ESM resolve-hook.
116
159
  let packageDirs = Dict.fromArray([
@@ -131,7 +174,7 @@ let make = (
131
174
  ->Pulumi.Input.make
132
175
 
133
176
  let lambda = Lambda.Function.make(
134
- ~name=serviceName,
177
+ ~name=name ++ "Lambda",
135
178
  ~args={
136
179
  handler: "index.handler"->Pulumi.Input.make,
137
180
  runtime: "nodejs22.x"->Pulumi.Input.make,
@@ -142,7 +185,7 @@ let make = (
142
185
  timeout: 30->Pulumi.Input.make,
143
186
  layers,
144
187
  tags: AWS.Tags.make(
145
- ~name=serviceName,
188
+ ~name=name ++ "Lambda",
146
189
  ~kind=ReventlessCore.ComponentType.Platform,
147
190
  ~role=Runtime,
148
191
  ~scope=Platform,
@@ -151,43 +194,87 @@ let make = (
151
194
  {
152
195
  Lambda.Function.variables: Dict.fromArray([
153
196
  ("Environment", Pulumi.Pulumi.getStackName()->Pulumi.Input.make),
154
- ("UPLOAD_BUCKET", bucketName),
155
- ("SERVED_PREFIX", Pulumi.Input.make(servedPrefix)),
197
+ ("UPLOAD_STORES", uploadStoresJson->Pulumi.Output.asInput),
198
+ ("RELEASE_WINDOW_SECONDS", releaseWindowSeconds->Int.toString->Pulumi.Input.make),
156
199
  ("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make),
157
200
  ("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make),
158
201
  ]),
159
202
  }: Lambda.Function.functionEnvironment
160
203
  )->Pulumi.Input.make,
161
204
  },
162
- ~opts?,
205
+ ~opts,
163
206
  )
164
207
 
165
- let functionUrl = FunctionUrl.make(
166
- ~name=`${serviceName}Url`,
167
- ~args={
168
- authorizationType: FunctionUrl.None,
169
- functionName: lambda.name->Pulumi.Output.asInput,
170
- cors: (
171
- {
172
- allowMethods: ["POST"]->Array.map(Pulumi.Input.make)->Pulumi.Input.make,
173
- allowOrigins: corsOrigins->Array.map(Pulumi.Input.make)->Pulumi.Input.make,
174
- // `content-type` and `authorization` named explicitly rather than `*`:
175
- // `Access-Control-Allow-Headers: *` is a wildcard that, per the Fetch
176
- // spec, does NOT cover `Authorization`. The presign POST carries a
177
- // bearer token, so a wildcard fails its preflight and the browser
178
- // rejects the request before it is sent — a silent break for every
179
- // authenticated upload.
180
- allowHeaders: ["content-type", "authorization"]
181
- ->Array.map(Pulumi.Input.make)
208
+ let dataSourceRole = IAM.Role.makeWithDefaultPolicy(
209
+ ~name=name ++ "DataSource",
210
+ ~servicePrincipal=AWS.AppSync.principal->Pulumi.Output.make,
211
+ ~tags=AWS.Tags.make(
212
+ ~name=name ++ "DataSource",
213
+ ~kind=ReventlessCore.ComponentType.Platform,
214
+ ~role=Identity,
215
+ ~scope=Platform,
216
+ ),
217
+ ~opts,
218
+ )
219
+
220
+ let _ =
221
+ (lambda.arn, dataSourceRole.id)
222
+ ->Pulumi.Output.all2
223
+ ->Pulumi.Output.apply(((lambdaArn, dataSourceRoleId)) => {
224
+ let _attach = IAM.RolePolicy.make(
225
+ ~name=name ++ "DataSource",
226
+ ~args={
227
+ policy: PolicyDocument.make(
228
+ ~id=name ++ "DataSourcePolicy",
229
+ ~statements=[
230
+ {
231
+ sid: "AllowDataSourceInvokeLambda",
232
+ effect: Allow,
233
+ actions: Action("lambda:InvokeFunction"),
234
+ resources: Resource(lambdaArn),
235
+ },
236
+ ],
237
+ )
238
+ ->PolicyDocument.toJsonString
182
239
  ->Pulumi.Input.make,
183
- }: FunctionUrl.cors
184
- )->Pulumi.Input.make,
240
+ role: dataSourceRoleId->Pulumi.Input.make,
241
+ },
242
+ ~opts,
243
+ )
244
+ })
245
+
246
+ let dataSource = AppSync.DataSource.make(
247
+ ~name=name ++ "DataSource",
248
+ ~args={
249
+ type_: AWS_LAMBDA,
250
+ apiId: api->Pulumi.Output.flatMap(api => api.id)->Pulumi.Output.asInput,
251
+ lambdaConfig: {
252
+ AppSync.DataSource.functionArn: lambda.arn->Pulumi.Output.asInput,
253
+ }->Pulumi.Input.make,
254
+ serviceRoleArn: dataSourceRole.arn->Pulumi.Output.asInput,
185
255
  },
186
- ~opts?,
256
+ ~opts=Some(opts),
257
+ )
258
+
259
+ let _presignResolver = AppSync_Resolver_Native.makeUnitJsResolver(
260
+ ~name=name ++ "Presign",
261
+ ~api,
262
+ ~dataSourceName=dataSource.name->Pulumi.Output.asInput,
263
+ ~type_="Mutation"->Pulumi.Input.make,
264
+ ~field="Upload_Presign"->Pulumi.Input.make,
265
+ ~code=invokeCode("presign"),
266
+ ~opts,
267
+ )
268
+
269
+ let _releaseResolver = AppSync_Resolver_Native.makeUnitJsResolver(
270
+ ~name=name ++ "Release",
271
+ ~api,
272
+ ~dataSourceName=dataSource.name->Pulumi.Output.asInput,
273
+ ~type_="Mutation"->Pulumi.Input.make,
274
+ ~field="Upload_Release"->Pulumi.Input.make,
275
+ ~code=invokeCode("release"),
276
+ ~opts,
187
277
  )
188
278
 
189
- {
190
- url: functionUrl.functionUrl,
191
- resources: [lambda.arn, functionUrl.functionArn],
192
- }
279
+ {resources: [lambda.arn]}
193
280
  }