1import * as IoTManagedIntegrations from "@/AWS/IoTManagedIntegrations";
2import * as Lambda from "@/AWS/Lambda";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "bindings-handler.ts");
12
13// Same synthetic Zigbee install-code QR payload the ManagedThing lifecycle
14// test uses — CreateManagedThing validates the payload shape, not that the
15// device exists. Checked in as a constant (never generated at test time).
16const ZIGBEE_QR_PAYLOAD = "Z:24FD5B0000015C63$I:83FED3407A939738";
17
18export class IoTMITestFunction extends Lambda.Function<Lambda.Function>()(
19 "IoTMITestFunction",
20) {}
21
22/**
23 * Every route answers `{ …fields }` on success or `{ errorTag }` when the
24 * operation fails with a TYPED error. The gated test asserts concrete fields
25 * where the API can succeed against an unprovisioned device, and asserts the
26 * tag is typed-and-not-AccessDenied elsewhere — which proves the binding
27 * wiring, the identifier injection, and the IAM grants.
28 */
29const errorTagged = <A, E extends { _tag: string }, R>(
30 effect: Effect.Effect<A, E, R>,
31): Effect.Effect<A | { errorTag: string }, never, R> =>
32 effect.pipe(
33 Effect.map((a): A | { errorTag: string } => a),
34 Effect.catch((e) => Effect.succeed({ errorTag: e._tag })),
35 );
36
37/**
38 * Bindings fixture: a credential locker + an (unprovisioned) Zigbee DEVICE
39 * managed thing, and a Lambda bound to every IoT Managed Integrations
40 * runtime binding. Deployed only by the gated Bindings test (the service
41 * exists in a handful of regions).
42 */
43export default IoTMITestFunction.make(
44 {
45 main,
46 functionUrl: true,
47 timeout: Duration.seconds(30),
48 },
49 Effect.gen(function* () {
50 const locker = yield* IoTManagedIntegrations.CredentialLocker("Locker", {});
51 const thing = yield* IoTManagedIntegrations.ManagedThing("Device", {
52 role: "DEVICE",
53 authenticationMaterial: Redacted.make(ZIGBEE_QR_PAYLOAD),
54 authenticationMaterialType: "ZIGBEE_QR_BAR_CODE",
55 credentialLockerId: locker.credentialLockerId,
56 serialNumber: "SN-ALCHEMY-BINDINGS-0001",
57 tags: { fixture: "iot-mi-bindings" },
58 });
59
60 const sendCommand =
61 yield* IoTManagedIntegrations.SendManagedThingCommand(thing);
62 const getState = yield* IoTManagedIntegrations.GetManagedThingState(thing);
63 const getCapabilities =
64 yield* IoTManagedIntegrations.GetManagedThingCapabilities(thing);
65 const getCertificate =
66 yield* IoTManagedIntegrations.GetManagedThingCertificate(thing);
67 const getConnectivity =
68 yield* IoTManagedIntegrations.GetManagedThingConnectivityData(thing);
69 const getMetaData =
70 yield* IoTManagedIntegrations.GetManagedThingMetaData(thing);
71 const listThingSchemas =
72 yield* IoTManagedIntegrations.ListManagedThingSchemas(thing);
73 const startDiscovery = yield* IoTManagedIntegrations.StartDeviceDiscovery();
74 const getDiscovery = yield* IoTManagedIntegrations.GetDeviceDiscovery();
75 const listDiscoveries =
76 yield* IoTManagedIntegrations.ListDeviceDiscoveries();
77 const listDiscovered =
78 yield* IoTManagedIntegrations.ListDiscoveredDevices();
79 const getSchemaVersion = yield* IoTManagedIntegrations.GetSchemaVersion();
80 const listSchemaVersions =
81 yield* IoTManagedIntegrations.ListSchemaVersions();
82 const getCustomEndpoint = yield* IoTManagedIntegrations.GetCustomEndpoint();
83 const sendConnectorEvent =
84 yield* IoTManagedIntegrations.SendConnectorEvent();
85
86 const bound = {
87 sendCommand,
88 getState,
89 getCapabilities,
90 getCertificate,
91 getConnectivity,
92 getMetaData,
93 listThingSchemas,
94 startDiscovery,
95 getDiscovery,
96 listDiscoveries,
97 listDiscovered,
98 getSchemaVersion,
99 listSchemaVersions,
100 getCustomEndpoint,
101 sendConnectorEvent,
102 };
103
104 return {
105 fetch: Effect.gen(function* () {
106 const request = yield* HttpServerRequest;
107 const url = new URL(request.originalUrl);
108 const pathname = url.pathname;
109
110 if (pathname === "/bindings") {
111 return yield* HttpServerResponse.json({ bound: Object.keys(bound) });
112 }
113
114 // -- account-level ops (Resource "*") ------------------------------
115 if (pathname === "/schema-versions") {
116 const result = yield* errorTagged(
117 listSchemaVersions({ Type: "capability", MaxResults: 3 }),
118 );
119 return yield* HttpServerResponse.json(
120 "errorTag" in result
121 ? result
122 : {
123 count: (result.Items ?? []).length,
124 first: result.Items?.[0],
125 },
126 );
127 }
128 if (pathname === "/schema-version") {
129 const id = url.searchParams.get("id") ?? "";
130 const result = yield* errorTagged(
131 getSchemaVersion({ Type: "capability", SchemaVersionedId: id }),
132 );
133 return yield* HttpServerResponse.json(
134 "errorTag" in result ? result : { schemaId: result.SchemaId },
135 );
136 }
137 if (pathname === "/custom-endpoint") {
138 const result = yield* errorTagged(getCustomEndpoint());
139 return yield* HttpServerResponse.json(
140 "errorTag" in result
141 ? result
142 : { endpointAddress: result.EndpointAddress },
143 );
144 }
145 if (pathname === "/discovery") {
146 // No live controller in the fixture — expect a TYPED rejection
147 // (never AccessDenied), which still proves wiring + IAM.
148 const result = yield* errorTagged(
149 startDiscovery({
150 DiscoveryType: "ZWAVE",
151 ControllerIdentifier: "alchemy-nonexistent-controller",
152 }),
153 );
154 return yield* HttpServerResponse.json(
155 "errorTag" in result ? result : { id: result.Id },
156 );
157 }
158 if (pathname === "/discoveries") {
159 const result = yield* errorTagged(
160 listDiscoveries({ StatusFilter: "RUNNING" }),
161 );
162 return yield* HttpServerResponse.json(
163 "errorTag" in result
164 ? result
165 : { count: (result.Items ?? []).length },
166 );
167 }
168 if (pathname === "/get-discovery") {
169 const result = yield* errorTagged(
170 getDiscovery({ Identifier: "alchemynonexistentdiscovery" }),
171 );
172 return yield* HttpServerResponse.json(
173 "errorTag" in result ? result : { status: result.Status },
174 );
175 }
176 if (pathname === "/discovered") {
177 const result = yield* errorTagged(
178 listDiscovered({ Identifier: "alchemynonexistentdiscovery" }),
179 );
180 return yield* HttpServerResponse.json(
181 "errorTag" in result
182 ? result
183 : { count: (result.Items ?? []).length },
184 );
185 }
186 if (pathname === "/connector-event") {
187 const result = yield* errorTagged(
188 sendConnectorEvent({
189 ConnectorId: "alchemynonexistentconnector",
190 Operation: "DEVICE_EVENT",
191 }),
192 );
193 return yield* HttpServerResponse.json(
194 "errorTag" in result ? result : { connectorId: result.ConnectorId },
195 );
196 }
197
198 // -- managed-thing-scoped ops (grant on the thing ARN) --------------
199 if (pathname === "/metadata") {
200 const result = yield* errorTagged(getMetaData());
201 return yield* HttpServerResponse.json(
202 "errorTag" in result
203 ? result
204 : { managedThingId: result.ManagedThingId },
205 );
206 }
207 if (pathname === "/capabilities") {
208 const result = yield* errorTagged(getCapabilities());
209 return yield* HttpServerResponse.json(
210 "errorTag" in result
211 ? result
212 : { managedThingId: result.ManagedThingId },
213 );
214 }
215 if (pathname === "/certificate") {
216 const result = yield* errorTagged(getCertificate());
217 return yield* HttpServerResponse.json(
218 "errorTag" in result
219 ? result
220 : { hasPem: result.CertificatePem !== undefined },
221 );
222 }
223 if (pathname === "/connectivity") {
224 const result = yield* errorTagged(getConnectivity());
225 return yield* HttpServerResponse.json(
226 "errorTag" in result ? result : { connected: result.Connected },
227 );
228 }
229 if (pathname === "/state") {
230 const result = yield* errorTagged(getState());
231 return yield* HttpServerResponse.json(
232 "errorTag" in result
233 ? result
234 : { endpoints: result.Endpoints.length },
235 );
236 }
237 if (pathname === "/thing-schemas") {
238 const result = yield* errorTagged(listThingSchemas());
239 return yield* HttpServerResponse.json(
240 "errorTag" in result
241 ? result
242 : { count: (result.Items ?? []).length },
243 );
244 }
245 if (pathname === "/command") {
246 // Unprovisioned device — expect a TYPED rejection (never
247 // AccessDenied), which still proves wiring + IAM.
248 const result = yield* errorTagged(
249 sendCommand({
250 Endpoints: [
251 {
252 endpointId: "1",
253 capabilities: [
254 {
255 id: "aws.OnOff",
256 name: "On/Off",
257 version: "1",
258 actions: [{ name: "activate" }],
259 },
260 ],
261 },
262 ],
263 }),
264 );
265 return yield* HttpServerResponse.json(
266 "errorTag" in result ? result : { traceId: result.TraceId },
267 );
268 }
269
270 return yield* HttpServerResponse.json(
271 { error: "Not found", pathname },
272 { status: 404 },
273 );
274 }).pipe(Effect.orDie),
275 };
276 }).pipe(
277 Effect.provide(
278 Layer.mergeAll(
279 IoTManagedIntegrations.SendManagedThingCommandHttp,
280 IoTManagedIntegrations.GetManagedThingStateHttp,
281 IoTManagedIntegrations.GetManagedThingCapabilitiesHttp,
282 IoTManagedIntegrations.GetManagedThingCertificateHttp,
283 IoTManagedIntegrations.GetManagedThingConnectivityDataHttp,
284 IoTManagedIntegrations.GetManagedThingMetaDataHttp,
285 IoTManagedIntegrations.ListManagedThingSchemasHttp,
286 IoTManagedIntegrations.StartDeviceDiscoveryHttp,
287 IoTManagedIntegrations.GetDeviceDiscoveryHttp,
288 IoTManagedIntegrations.ListDeviceDiscoveriesHttp,
289 IoTManagedIntegrations.ListDiscoveredDevicesHttp,
290 IoTManagedIntegrations.GetSchemaVersionHttp,
291 IoTManagedIntegrations.ListSchemaVersionsHttp,
292 IoTManagedIntegrations.GetCustomEndpointHttp,
293 IoTManagedIntegrations.SendConnectorEventHttp,
294 ),
295 ),
296 ),
297);