@reventlessdev/reventless-aws 3.0.0-alpha.227 → 3.0.0-alpha.228

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.
@@ -0,0 +1,168 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Aws from "@pulumi/aws";
4
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
+ import * as IAM$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/IAM.res.mjs";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Pulumi from "@pulumi/pulumi";
8
+ import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
9
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
10
+ import * as AWS$ReventlessAws from "../AWS.res.mjs";
11
+ import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
12
+ import * as ClientLocation from "@aws-sdk/client-location";
13
+ import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
14
+ import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
15
+
16
+ let getEnv = (function(k) { var v = process.env[k]; return (v === undefined || v === null || v === "") ? undefined : v; });
17
+
18
+ function corsHeaders() {
19
+ return Object.fromEntries([
20
+ [
21
+ "content-type",
22
+ "application/json"
23
+ ],
24
+ [
25
+ "access-control-allow-origin",
26
+ "*"
27
+ ],
28
+ [
29
+ "access-control-allow-methods",
30
+ "GET,OPTIONS"
31
+ ],
32
+ [
33
+ "access-control-allow-headers",
34
+ "*"
35
+ ]
36
+ ]);
37
+ }
38
+
39
+ function readQueryParam(event) {
40
+ let q = Stdlib_Option.flatMap(event.queryStringParameters, p => p["q"]);
41
+ if (q !== undefined) {
42
+ return q;
43
+ } else {
44
+ return Stdlib_Option.flatMap(event.rawQueryString, raw => Stdlib_Array.findMap(raw.split("&"), pair => {
45
+ let match = pair.split("=");
46
+ if (match.length !== 2) {
47
+ return;
48
+ }
49
+ let k = match[0];
50
+ let v = match[1];
51
+ if (k === "q") {
52
+ return decodeURIComponent(v);
53
+ }
54
+ }));
55
+ }
56
+ }
57
+
58
+ async function handleGeocode(event, _context) {
59
+ try {
60
+ let indexName = Stdlib_Option.getOr(getEnv("PLACE_INDEX_NAME"), "");
61
+ let q = Stdlib_Option.getOr(readQueryParam(event), "");
62
+ if (indexName === "" || q === "") {
63
+ return {
64
+ statusCode: 200,
65
+ headers: corsHeaders(),
66
+ body: "[]"
67
+ };
68
+ }
69
+ let client = new ClientLocation.LocationClient();
70
+ let resp = await client.send(new ClientLocation.SearchPlaceIndexForTextCommand({
71
+ IndexName: indexName,
72
+ Text: q,
73
+ MaxResults: 5
74
+ }));
75
+ let results = Stdlib_Array.filterMap(Stdlib_Option.getOr(resp.Results, []), r => {
76
+ let place = r.Place;
77
+ if (place === undefined) {
78
+ return;
79
+ }
80
+ let label = Stdlib_Option.getOr(place.Label, "");
81
+ let pt = Stdlib_Option.flatMap(place.Geometry, g => g.Point);
82
+ if (pt === undefined) {
83
+ return;
84
+ }
85
+ if (pt.length < 2) {
86
+ return;
87
+ }
88
+ let lng = pt[0];
89
+ let lat = pt[1];
90
+ return Object.fromEntries([
91
+ [
92
+ "label",
93
+ label
94
+ ],
95
+ [
96
+ "lat",
97
+ lat
98
+ ],
99
+ [
100
+ "lng",
101
+ lng
102
+ ]
103
+ ]);
104
+ });
105
+ return {
106
+ statusCode: 200,
107
+ headers: corsHeaders(),
108
+ body: JSON.stringify(results)
109
+ };
110
+ } catch (exn) {
111
+ return {
112
+ statusCode: 200,
113
+ headers: corsHeaders(),
114
+ body: "[]"
115
+ };
116
+ }
117
+ }
118
+
119
+ function make(placeIndexName, corsOriginsOpt, opts) {
120
+ let corsOrigins = corsOriginsOpt !== undefined ? corsOriginsOpt : ["*"];
121
+ let serviceName = "GeocoderService";
122
+ let opts$1 = Stdlib_Option.map(opts, Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions);
123
+ let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(serviceName, Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(serviceName, "Platform", "Identity", "Platform", undefined, undefined, undefined, undefined), opts$1);
124
+ placeIndexName.apply(idx => {
125
+ let arn = `arn:aws:geo:*:*:place-index/` + idx;
126
+ new (Aws.iam.RolePolicy)(serviceName + `Policy`, {
127
+ policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, serviceName + `Policy`, [{
128
+ Sid: "AllowGeocode",
129
+ Effect: "Allow",
130
+ Action: "geo:SearchPlaceIndexForText",
131
+ Resource: arn
132
+ }])),
133
+ role: lambdaRole.id
134
+ }, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
135
+ });
136
+ let environment = {
137
+ variables: Object.fromEntries([[
138
+ "PLACE_INDEX_NAME",
139
+ placeIndexName
140
+ ]])
141
+ };
142
+ let lambda = new (Aws.lambda.CallbackFunction)(serviceName, Lambda$PulumiAws.CallbackFunction.Args.make(handleGeocode, lambdaRole, undefined, undefined, undefined, undefined, 30, undefined, undefined, undefined, AWS_Tags$ReventlessAws.make(serviceName, "Platform", "Runtime", "Platform", undefined, undefined, undefined, undefined), environment), opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
143
+ let functionUrl = new (Aws.lambda.FunctionUrl)(serviceName + `Url`, {
144
+ authorizationType: "NONE",
145
+ functionName: lambda.name,
146
+ cors: {
147
+ allowHeaders: ["*"].map(prim => prim),
148
+ allowMethods: ["GET"].map(prim => prim),
149
+ allowOrigins: corsOrigins.map(prim => prim)
150
+ }
151
+ }, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
152
+ return {
153
+ url: functionUrl.functionUrl,
154
+ resources: [
155
+ lambda.arn,
156
+ functionUrl.functionArn
157
+ ]
158
+ };
159
+ }
160
+
161
+ export {
162
+ getEnv,
163
+ corsHeaders,
164
+ readQueryParam,
165
+ handleGeocode,
166
+ make,
167
+ }
168
+ /* @pulumi/aws Not a pure module */
@@ -8,10 +8,11 @@
8
8
  // backend as AppSync resolvers. Event history resources read directly from
9
9
  // EventLog and DcbEventLog DynamoDB tables.
10
10
  //
11
- // NOTE: Lambda Function URL bindings for rescript-pulumi-aws are not yet
12
- // available. This module provides the runtime MCP server handler that will
13
- // be deployed once the Pulumi bindings are added. The deploy-time infrastructure
14
- // (Lambda + Function URL + IAM role) is documented but not yet wired to Pulumi.
11
+ // NOTE: This module provides the runtime MCP server handler. The Lambda
12
+ // Function URL binding now exists (PulumiAws.Lambda.FunctionUrl), so the
13
+ // deploy-time infrastructure (Lambda + Function URL + IAM role) can be wired to
14
+ // Pulumi following the Geocoder_AwsLocation / Upload_Presign_S3 adapters; the
15
+ // section below still documents the concrete shape that wiring takes.
15
16
 
16
17
  // ─── Runtime handler ───────────────────────────────────────────────────────
17
18
 
@@ -410,10 +411,10 @@ let dispatchTool = async (
410
411
  )
411
412
  }
412
413
 
413
- // ─── Deploy-time infrastructure (placeholder) ─────────────────────────────
414
+ // ─── Deploy-time infrastructure (documented shape) ────────────────────────
414
415
  //
415
- // When rescript-pulumi-aws gains Lambda Function URL bindings, this section
416
- // will create:
416
+ // With the Lambda Function URL binding now available, this section documents
417
+ // the resources a full MCP deploy path creates:
417
418
  //
418
419
  // 1. Lambda function (Node.js 20.x runtime)
419
420
  // - Handler: mcp-handler.handler
@@ -262,7 +262,13 @@ let makeFromCodeAsset: (
262
262
  // is no race against Lambda's lazy auto-created group. Namespace/dimension
263
263
  // are CloudWatch-specific and live here, not in core (provider-neutral).
264
264
  if dcbMetrics {
265
- ["AppendRetry", "AppendConflict"]->Array.forEach(metricName => {
265
+ [
266
+ "AppendRetry",
267
+ "AppendConflict",
268
+ "DcbDecisionModelCacheHit",
269
+ "DcbDecisionModelCacheMiss",
270
+ "DcbDecisionModelDeltaEventCount",
271
+ ]->Array.forEach(metricName => {
266
272
  let _ = Cloudwatch.LogMetricFilter.make(
267
273
  ~name=`${name}${metricName}Filter`,
268
274
  ~args={
@@ -126,7 +126,10 @@ function makeFromCodeAsset(name, unitKind, componentKind, code, sourceCodeHash,
126
126
  if (dcbMetrics) {
127
127
  [
128
128
  "AppendRetry",
129
- "AppendConflict"
129
+ "AppendConflict",
130
+ "DcbDecisionModelCacheHit",
131
+ "DcbDecisionModelCacheMiss",
132
+ "DcbDecisionModelDeltaEventCount"
130
133
  ].forEach(metricName => {
131
134
  new (Aws.cloudwatch.LogMetricFilter)(name + metricName + `Filter`, {
132
135
  pattern: `{ $.reventlessMetric = "` + metricName + `" }`,
@@ -71,8 +71,14 @@ export function buildJsonEventsHandler(specModule, projectionModule, queryDbTabl
71
71
  Stream.flatMap(
72
72
  Stream.mapEffect(stream, json => Effect.sync(() => {
73
73
  try {
74
+ // Events arrive as `{id, meta, recordedAt, event}` envelopes
75
+ // (Util_DynamoDbStream_Runtime.buildJsonEvent' / PgChangeFeedRelay).
76
+ // Surface `meta` + `recordedAt` to the projection as the `consumed`
77
+ // envelope; `recordedAt` defaults to "" if a producer omitted it.
74
78
  const eventJson = json.event != null ? json.event : json;
75
- return project(parseJsonOrThrow(eventJson, eventSchema));
79
+ const meta = json.meta;
80
+ const recordedAt = json.recordedAt != null ? json.recordedAt : "";
81
+ return project({ event: parseJsonOrThrow(eventJson, eventSchema), meta, recordedAt });
76
82
  } catch (exn) {
77
83
  log.error("failed to decode event", { comp: "StateViewSliceRuntime", detail: exn && exn.message ? exn.message : String(exn) });
78
84
  return [];
@@ -132,11 +132,11 @@ let make: ReventlessCore.Task_Adapter.bucketMaker<bucketParts> = (~name, ~opts)
132
132
  corsRules: [
133
133
  {
134
134
  PulumiAws.S3.Bucket.allowedHeaders: ["*"],
135
- allowedMethods: ["HEAD", "GET"],
135
+ allowedMethods: ["HEAD", "GET", "PUT", "POST"],
136
136
  allowedOrigins: ["*"],
137
137
  exposeHeaders: [
138
138
  "x-amz-server-side-encryption",
139
- ">x-amz-request-id",
139
+ "x-amz-request-id",
140
140
  "x-amz-id-2",
141
141
  "ETag",
142
142
  ],
@@ -80,12 +80,14 @@ function make(name, opts) {
80
80
  allowedHeaders: ["*"],
81
81
  allowedMethods: [
82
82
  "HEAD",
83
- "GET"
83
+ "GET",
84
+ "PUT",
85
+ "POST"
84
86
  ],
85
87
  allowedOrigins: ["*"],
86
88
  exposeHeaders: [
87
89
  "x-amz-server-side-encryption",
88
- ">x-amz-request-id",
90
+ "x-amz-request-id",
89
91
  "x-amz-id-2",
90
92
  "ETag"
91
93
  ],
@@ -0,0 +1,258 @@
1
+ // Direct-to-S3 upload presign service behind a public Lambda Function URL.
2
+ //
3
+ // Deploy-time: `make` provisions a CallbackFunction (handler closure serialized
4
+ // by Pulumi), an IAM execution role scoped to `s3:PutObject` on the target
5
+ // bucket, and a Function URL (no auth) so a browser can request a presigned PUT.
6
+ //
7
+ // Runtime: `handlePresign` reads a JSON body `{fileName, contentType}`, derives
8
+ // a target key `uploads/<identity?>/<uuid>/<fileName>`, presigns a PUT to the
9
+ // bucket named in `UPLOAD_BUCKET` (expires in 300s), and returns
10
+ // `{uploadUrl, storageRef}`. A Bearer token, when present, is decoded (no
11
+ // signature verification here — that is the auth layer's job) and its `sub`
12
+ // namespaces the key; anonymous callers are still served for the demo.
13
+
14
+ open PulumiAws
15
+
16
+ // ── AWS SDK v3 bindings ─────────────────────────────────────────────────────
17
+
18
+ type s3Client
19
+
20
+ type putObjectInput = {
21
+ @as("Bucket") bucket: string,
22
+ @as("Key") key: string,
23
+ @as("ContentType") contentType?: string,
24
+ }
25
+ type putObjectCommand
26
+
27
+ @module("@aws-sdk/client-s3") @new external makeS3Client: unit => s3Client = "S3Client"
28
+
29
+ @module("@aws-sdk/client-s3") @new
30
+ external makePutObjectCommand: putObjectInput => putObjectCommand = "PutObjectCommand"
31
+
32
+ type presignOptions = {expiresIn: int}
33
+
34
+ @module("@aws-sdk/s3-request-presigner")
35
+ external getSignedUrl: (s3Client, putObjectCommand, presignOptions) => promise<string> =
36
+ "getSignedUrl"
37
+
38
+ // ── Function URL event / response shapes (payload format 2.0) ────────────────
39
+
40
+ type functionUrlEvent = {
41
+ body?: string,
42
+ headers?: dict<string>,
43
+ }
44
+
45
+ type response = {
46
+ statusCode: int,
47
+ headers?: dict<string>,
48
+ body: string,
49
+ }
50
+
51
+ let getEnv: string => option<string> = %raw(`
52
+ function(k) { var v = process.env[k]; return (v === undefined || v === null || v === "") ? undefined : v; }
53
+ `)
54
+
55
+ // Node 22 exposes globalThis.crypto.randomUUID; fall back to node:crypto.
56
+ let genUuid: unit => string = %raw(`
57
+ function() {
58
+ return (globalThis.crypto && globalThis.crypto.randomUUID)
59
+ ? globalThis.crypto.randomUUID()
60
+ : require("crypto").randomUUID();
61
+ }
62
+ `)
63
+
64
+ // Decode a JWT payload's `sub` claim without verifying the signature (the auth
65
+ // layer verifies; here we only namespace the storage key).
66
+ let decodeJwtSub: string => option<string> = %raw(`
67
+ function(header) {
68
+ try {
69
+ var token = header.indexOf("Bearer ") === 0 ? header.slice(7) : header;
70
+ var parts = token.split(".");
71
+ if (parts.length < 2) return undefined;
72
+ var base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
73
+ var json = Buffer.from(base64, "base64").toString("utf8");
74
+ var claims = JSON.parse(json);
75
+ return (claims && claims.sub) ? String(claims.sub) : undefined;
76
+ } catch (_) { return undefined; }
77
+ }
78
+ `)
79
+
80
+ let corsHeaders = () =>
81
+ Dict.fromArray([
82
+ ("content-type", "application/json"),
83
+ ("access-control-allow-origin", "*"),
84
+ ("access-control-allow-methods", "POST,OPTIONS"),
85
+ ("access-control-allow-headers", "*"),
86
+ ])
87
+
88
+ // Key prefix from the caller identity when a Bearer token is present.
89
+ let identityPrefix = (event: functionUrlEvent): string => {
90
+ let authHeader =
91
+ event.headers->Option.flatMap(h =>
92
+ h->Dict.get("authorization")->Option.orElse(h->Dict.get("Authorization"))
93
+ )
94
+ switch authHeader->Option.flatMap(decodeJwtSub) {
95
+ | Some(sub) => `${sub}/`
96
+ | None => ""
97
+ }
98
+ }
99
+
100
+ // ── Runtime handler ─────────────────────────────────────────────────────────
101
+
102
+ let handlePresign = async (event: functionUrlEvent, _context: Lambda.context): response => {
103
+ try {
104
+ let bucket = getEnv("UPLOAD_BUCKET")->Option.getOr("")
105
+ let parsed =
106
+ event.body
107
+ ->Option.getOr("{}")
108
+ ->JSON.parseOrThrow
109
+ ->JSON.Decode.object
110
+ ->Option.getOr(Dict.make())
111
+ let fileName =
112
+ parsed->Dict.get("fileName")->Option.flatMap(JSON.Decode.string)->Option.getOr("upload")
113
+ let contentType = parsed->Dict.get("contentType")->Option.flatMap(JSON.Decode.string)
114
+
115
+ // The object key doubles as the served path segment: it is rooted at the
116
+ // served prefix (`SERVED_PREFIX`, e.g. `uploads`) so the CloudFront
117
+ // `{prefix}/*` behavior fronts it. Callers PUT to this exact key.
118
+ let servedPrefix = getEnv("SERVED_PREFIX")->Option.getOr("uploads")
119
+ let key = `${servedPrefix}/${identityPrefix(event)}${genUuid()}/${fileName}`
120
+ let client = makeS3Client()
121
+ let command = makePutObjectCommand({bucket, key, contentType: ?contentType})
122
+ let uploadUrl = await getSignedUrl(client, command, {expiresIn: 300})
123
+
124
+ // The stored ref is a same-origin relative URL `/{key}`: the served bucket is
125
+ // fronted read-only by the UI's own CloudFront distribution under
126
+ // `{prefix}/*`, so a command stores this directly-renderable value and an
127
+ // `image`-semantic field thumbnails it with no post-processing and no public
128
+ // bucket. See [docs/plans/done/ui-served-buckets.md].
129
+ let storageRef = `/${key}`
130
+
131
+ {
132
+ statusCode: 200,
133
+ headers: corsHeaders(),
134
+ body: Dict.fromArray([
135
+ ("uploadUrl", JSON.Encode.string(uploadUrl)),
136
+ ("storageRef", JSON.Encode.string(storageRef)),
137
+ ])
138
+ ->JSON.Encode.object
139
+ ->JSON.stringify,
140
+ }
141
+ } catch {
142
+ | _ => {
143
+ statusCode: 400,
144
+ headers: corsHeaders(),
145
+ body: Dict.fromArray([("error", JSON.Encode.string("presign_failed"))])
146
+ ->JSON.Encode.object
147
+ ->JSON.stringify,
148
+ }
149
+ }
150
+ }
151
+
152
+ // ── Deploy-time factory ─────────────────────────────────────────────────────
153
+
154
+ type serviceOutputs = {
155
+ url: Pulumi.Output.t<string>,
156
+ resources: array<Pulumi.Output.t<string>>,
157
+ }
158
+
159
+ let make = (
160
+ ~bucketName: Pulumi.Input.t<string>,
161
+ ~corsOrigins: array<string>=["*"],
162
+ // Prefix the presigned object keys are rooted at; must match the served
163
+ // bucket's CloudFront `{prefix}/*` behavior so the returned `/{key}` ref
164
+ // resolves. Defaults to `uploads`.
165
+ ~servedPrefix: string="uploads",
166
+ ~opts=?,
167
+ ): serviceOutputs => {
168
+ let serviceName = "UploadPresignService"
169
+ let opts =
170
+ opts->Option.map(ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions)
171
+
172
+ let lambdaRole = IAM.Role.makeWithDefaultPolicy(
173
+ ~name=serviceName,
174
+ ~servicePrincipal=AWS.Lambda.principal->Pulumi.Output.make,
175
+ ~tags=AWS.Tags.make(
176
+ ~name=serviceName,
177
+ ~kind=ReventlessCore.ComponentType.Platform,
178
+ ~role=Identity,
179
+ ~scope=Platform,
180
+ ),
181
+ ~opts?,
182
+ )
183
+
184
+ // Least-privilege: PutObject on any key under the target bucket.
185
+ let _policy =
186
+ bucketName
187
+ ->Pulumi.Output.fromInput
188
+ ->Pulumi.Output.apply(b => {
189
+ let arn = `arn:aws:s3:::${b}/*`
190
+ let _ = IAM.RolePolicy.make(
191
+ ~name=`${serviceName}Policy`,
192
+ ~args={
193
+ policy: PolicyDocument.make(
194
+ ~id=`${serviceName}Policy`,
195
+ ~statements=[
196
+ {
197
+ sid: "AllowUploadPut",
198
+ effect: Allow,
199
+ actions: Action("s3:PutObject"),
200
+ resources: Resource(arn),
201
+ },
202
+ ],
203
+ )
204
+ ->PolicyDocument.toJsonString
205
+ ->Pulumi.Input.make,
206
+ role: lambdaRole.id->Pulumi.Output.asInput,
207
+ },
208
+ ~opts?,
209
+ )
210
+ })
211
+
212
+ let environment: Lambda.CallbackFunction.Args.functionEnvironment = {
213
+ // CallbackFunction under-types env values as `dict<string>`; Pulumi resolves
214
+ // Output-valued variables at deploy time, so bridge the Input here.
215
+ variables: Dict.fromArray([
216
+ ("UPLOAD_BUCKET", bucketName),
217
+ ("SERVED_PREFIX", Pulumi.Input.make(servedPrefix)),
218
+ ])->Obj.magic,
219
+ }
220
+
221
+ let lambda = Lambda.CallbackFunction.make(
222
+ ~name=serviceName,
223
+ ~args=Lambda.CallbackFunction.Args.make(
224
+ ~callback=handlePresign,
225
+ ~role=lambdaRole,
226
+ ~environment,
227
+ ~timeout=30->Pulumi.Input.make,
228
+ ~tags=AWS.Tags.make(
229
+ ~name=serviceName,
230
+ ~kind=ReventlessCore.ComponentType.Platform,
231
+ ~role=Runtime,
232
+ ~scope=Platform,
233
+ ),
234
+ ),
235
+ ~opts?,
236
+ )
237
+
238
+ let functionUrl = FunctionUrl.make(
239
+ ~name=`${serviceName}Url`,
240
+ ~args={
241
+ authorizationType: FunctionUrl.None,
242
+ functionName: lambda.name->Pulumi.Output.asInput,
243
+ cors: (
244
+ {
245
+ allowMethods: ["POST"]->Array.map(Pulumi.Input.make)->Pulumi.Input.make,
246
+ allowOrigins: corsOrigins->Array.map(Pulumi.Input.make)->Pulumi.Input.make,
247
+ allowHeaders: ["*"]->Array.map(Pulumi.Input.make)->Pulumi.Input.make,
248
+ }: FunctionUrl.cors
249
+ )->Pulumi.Input.make,
250
+ },
251
+ ~opts?,
252
+ )
253
+
254
+ {
255
+ url: functionUrl.functionUrl,
256
+ resources: [lambda.arn, functionUrl.functionArn],
257
+ }
258
+ }