1import * as Effect from "effect/Effect";
2import * as Binding from "../../Binding.ts";
3import * as Output from "../../Output.ts";
4import { AWSEnvironment } from "../Environment.ts";
5import { isBindingHost } from "../Lambda/Function.ts";
6import type { Thing } from "./Thing.ts";
7
8/**
9 * Shared scaffolding for AWS IoT HTTP bindings.
10 *
11 * NOT exported from `index.ts` — every near-identical `{Op}Http.ts` in this
12 * service is a thin `Layer.effect(Cap, make…HttpBinding({ … }))` over one of
13 * the builders below. Everything except the operation, the IAM action list,
14 * and the identifier scope (account / thing / topic filter / client filter)
15 * is boilerplate.
16 */
17
18const currentEnvironment = Effect.gen(function* () {
19 const { accountId, region } =
20 yield* AWSEnvironment.current as unknown as Effect.Effect<{
21 accountId: string;
22 region: string;
23 }>;
24 return { accountId, region };
25});
26
27/**
28 * Build the impl Effect for an account-level operation (`DescribeEndpoint`,
29 * `ListThings`, `ListRetainedMessages`): the runtime callable passes the
30 * caller's request through unchanged and the deploy-time half grants
31 * `actions` on `*` (these IoT actions do not support resource-level
32 * permissions).
33 */
34export const makeIotAccountHttpBinding = <I, A, E, R>(options: {
35 /** Fully-qualified binding tag, e.g. `AWS.IoT.DescribeEndpoint`. */
36 tag: string;
37 /** The distilled operation, invoked with the caller's request as-is. */
38 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
39 /** IAM actions granted on `*`. */
40 actions: readonly string[];
41}) =>
42 Effect.gen(function* () {
43 const op = yield* options.operation;
44
45 return Effect.fn(function* () {
46 if (!globalThis.__ALCHEMY_RUNTIME__) {
47 const host = yield* Binding.Host;
48 if (isBindingHost(host)) {
49 yield* host.bind`Allow(${host}, ${options.tag}())`({
50 policyStatements: [
51 {
52 Effect: "Allow",
53 Action: [...options.actions],
54 Resource: ["*"],
55 },
56 ],
57 });
58 }
59 }
60 return Effect.fn(options.tag)(function* (request?: I) {
61 return yield* op((request ?? {}) as I);
62 });
63 });
64 });
65
66/**
67 * Build the impl Effect for a thing-scoped operation (device shadows,
68 * `DescribeThing`): the runtime callable injects the bound {@link Thing}'s
69 * physical name as `thingName` and the deploy-time half grants `actions` on
70 * the thing ARN.
71 */
72export const makeIotThingHttpBinding = <
73 I extends { thingName: string },
74 A,
75 E,
76 R,
77>(options: {
78 /** Fully-qualified binding tag, e.g. `AWS.IoT.GetThingShadow`. */
79 tag: string;
80 /** The distilled operation; `thingName` is injected from the thing. */
81 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
82 /** IAM actions granted on the thing ARN. */
83 actions: readonly string[];
84}) =>
85 Effect.gen(function* () {
86 const op = yield* options.operation;
87
88 return Effect.fn(function* (thing: Thing) {
89 const thingName = yield* thing.thingName;
90 if (!globalThis.__ALCHEMY_RUNTIME__) {
91 const host = yield* Binding.Host;
92 if (isBindingHost(host)) {
93 yield* host.bind`Allow(${host}, ${options.tag}(${thing}))`({
94 policyStatements: [
95 {
96 Effect: "Allow",
97 Action: [...options.actions],
98 // `thing/<name>` authorizes the classic shadow and the
99 // registry; `thing/<name>/*` authorizes NAMED shadows,
100 // whose IAM resource is `thing/<thingName>/<shadowName>`.
101 Resource: [
102 thing.thingArn,
103 Output.interpolate`${thing.thingArn}/*`,
104 ],
105 },
106 ],
107 });
108 }
109 }
110 return Effect.fn(`${options.tag}(${thing.LogicalId})`)(function* (
111 request?: Omit<I, "thingName">,
112 ) {
113 return yield* op({
114 ...request,
115 thingName: yield* thingName,
116 } as I);
117 });
118 });
119 });
120
121/**
122 * Build the impl Effect for a topic-scoped operation (`Publish`,
123 * `GetRetainedMessage`): the binding takes an MQTT topic filter, the
124 * deploy-time half grants `actions` on the matching topic ARN
125 * (`arn:aws:iot:{region}:{account}:topic/{filter}`, or all topics when the
126 * filter is omitted), and the runtime callable passes the caller's request
127 * (which carries the concrete `topic`) through unchanged.
128 */
129export const makeIotTopicHttpBinding = <I, A, E, R>(options: {
130 /** Fully-qualified binding tag, e.g. `AWS.IoT.Publish`. */
131 tag: string;
132 /** The distilled operation, invoked with the caller's request as-is. */
133 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
134 /** IAM actions granted on the topic-filter ARN. */
135 actions: readonly string[];
136}) =>
137 Effect.gen(function* () {
138 const op = yield* options.operation;
139
140 return Effect.fn(function* (topicFilter?: string) {
141 if (!globalThis.__ALCHEMY_RUNTIME__) {
142 const host = yield* Binding.Host;
143 if (isBindingHost(host)) {
144 const { accountId, region } = yield* currentEnvironment;
145 yield* host.bind`Allow(${host}, ${options.tag}(${topicFilter ?? "*"}))`(
146 {
147 policyStatements: [
148 {
149 Effect: "Allow",
150 Action: [...options.actions],
151 Resource: [
152 `arn:aws:iot:${region}:${accountId}:topic/${topicFilter ?? "*"}`,
153 ],
154 },
155 ],
156 },
157 );
158 }
159 }
160 return Effect.fn(`${options.tag}(${topicFilter ?? "*"})`)(function* (
161 request: I,
162 ) {
163 return yield* op(request);
164 });
165 });
166 });
167
168/**
169 * Build the impl Effect for a client-scoped MQTT connection operation
170 * (`GetConnection`, `DeleteConnection`, `ListSubscriptions`,
171 * `SendDirectMessage`): the binding takes a client id filter, the
172 * deploy-time half grants `actions` on the matching client ARN
173 * (`arn:aws:iot:{region}:{account}:client/{filter}`, or all clients when the
174 * filter is omitted), and the runtime callable passes the caller's request
175 * (which carries the concrete `clientId`) through unchanged.
176 */
177export const makeIotClientHttpBinding = <I, A, E, R>(options: {
178 /** Fully-qualified binding tag, e.g. `AWS.IoT.GetConnection`. */
179 tag: string;
180 /** The distilled operation, invoked with the caller's request as-is. */
181 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
182 /** IAM actions granted on the client-filter ARN. */
183 actions: readonly string[];
184}) =>
185 Effect.gen(function* () {
186 const op = yield* options.operation;
187
188 return Effect.fn(function* (clientIdFilter?: string) {
189 if (!globalThis.__ALCHEMY_RUNTIME__) {
190 const host = yield* Binding.Host;
191 if (isBindingHost(host)) {
192 const { accountId, region } = yield* currentEnvironment;
193 yield* host.bind`Allow(${host}, ${options.tag}(${clientIdFilter ?? "*"}))`(
194 {
195 policyStatements: [
196 {
197 Effect: "Allow",
198 Action: [...options.actions],
199 Resource: [
200 `arn:aws:iot:${region}:${accountId}:client/${clientIdFilter ?? "*"}`,
201 ],
202 },
203 ],
204 },
205 );
206 }
207 }
208 return Effect.fn(`${options.tag}(${clientIdFilter ?? "*"})`)(function* (
209 request: I,
210 ) {
211 return yield* op(request);
212 });
213 });
214 });