@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.
- package/CHANGELOG.md +11 -0
- package/package.json +11 -8
- package/src/Platform.res +98 -9
- package/src/Platform.res.mjs +73 -8
- package/src/adapter/Geocoder/Geocoder_AwsLocation.res +233 -0
- package/src/adapter/Geocoder/Geocoder_AwsLocation.res.mjs +168 -0
- package/src/adapter/Mcp/MCP_Lambda.res +8 -7
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +7 -1
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +4 -1
- package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +7 -1
- package/src/adapter/Task/TaskBucket_S3.res +2 -2
- package/src/adapter/Task/TaskBucket_S3.res.mjs +4 -2
- package/src/adapter/Upload/Upload_Presign_S3.res +258 -0
- package/src/adapter/Upload/Upload_Presign_S3.res.mjs +170 -0
- package/src/plugin/stack/Plugin_Stack.res +71 -8
- package/src/plugin/stack/Plugin_Stack.res.mjs +57 -8
- package/src/util/Util_DynamoDbStream_Runtime.res +6 -0
- package/src/util/Util_DynamoDbStream_Runtime.res.mjs +5 -0
- package/src/util/Util_StaticBundle.res +21 -0
- package/src/util/Util_StaticBundle.res.mjs +14 -0
- package/tests/Util_StaticBundleTest.res +40 -0
- package/tests/Util_StaticBundleTest.res.mjs +44 -0
- package/tests/integration/SvsTestSlice_Projection.res +1 -1
- package/tests/integration/SvsTestSlice_Projection.res.mjs +2 -1
|
@@ -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:
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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 (
|
|
414
|
+
// ─── Deploy-time infrastructure (documented shape) ────────────────────────
|
|
414
415
|
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
+
}
|