foobara-aws 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f4de7e0012dfa7d11c92054cd25827b8b7f2e0864bf779027fe8c35a754c423e
4
+ data.tar.gz: b5009b5e2c62ba0db20ba010ad3580afec5c0524733fd8a8aa09f9a0afa8e475
5
+ SHA512:
6
+ metadata.gz: cb24ae7b484641d23c71c27ff59743ff1a2ac60eb0eea8c672da9c7177d05083cecfc535b47e722c09f995ca70fd9c8b32ea74ca1c5b6e2f2b0767ee9d58eedd
7
+ data.tar.gz: 87c12595977a88553fc092c6aa050dd22dcb25d433e5593d5b0594958b0e16cd42e47ae6553fcec8957089a8aeab68e620b251974a8e2da4bd38a65115793a29
data/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-08-04
4
+
5
+ - Initial release. Renamed from foobara-cdk before publishing: half of it is runtime code that loads inside a
6
+ Lambda, where "cdk" in the namespace would be a lie and CDK on the load path would be cold-start cost for nothing.
7
+ - `Foobara::AWS::Handler` — API Gateway v2 to Rack, with identity taken from the authorizer's verified claims.
8
+ Owns `Foobara::AWS.current_caller`, which is the answer to "who is calling?" for a command that is public and
9
+ viewer-aware — something Foobara's `requires_authentication` cannot express.
10
+ - `Foobara::AWS::Authorizer` — an optional-auth REQUEST authorizer. Verifies a token when one is present, lets
11
+ anonymous callers through on the commands the manifest says are public, and refuses everything else.
12
+ - `Foobara::AWS::Packager` — one artifact per unit: sources, a generated handler, and a standalone gem bundle
13
+ built in a Lambda-like container. Units whose gemfiles resolve identically share one bundle build; gems declared
14
+ by `path:` are copied in and their load paths rewritten, since bundler writes those relative to the bundle and
15
+ they would not survive being copied into an artifact.
16
+ - `Service` gained `tracing:` and `log_retention:`. `tracing: :active` is the cold-start measure worth having:
17
+ X-Ray splits a cold invocation into an Initialization subsegment and the handler's own work.
18
+ - Per-command units are closed over `depends_on`, transitively and cycle-safely.
19
+ - `Foobara::AWS.plan(manifest)` — reads a Foobara manifest into a deployment plan: one unit per domain
20
+ (or organization, or command), its route, and which of its commands are callable without authentication.
21
+ Plain data; needs neither aws-cdk-lib nor Foobara itself.
22
+ - `Plan#to_h` / `Plan.load` — the plan survives JSON, so it can be produced at build time where the app is and
23
+ consumed at synth time where the infrastructure is.
24
+ - `Foobara::AWS::CDK::Service` — synthesises the plan into one Lambda per unit behind an HTTP API, with greedy routes
25
+ and an optional REQUEST authorizer. Raises if a unit's artifact is missing rather than deploying a route to
26
+ nothing.
27
+ - `Foobara::AWS::Lambda` — `aws_lambda memory:/vcpu:/timeout:` on a command, carried through the manifest.
28
+ The one deployment fact a manifest cannot otherwise supply. `vcpu:` resolves to memory, since Lambda allocates
29
+ CPU in proportion to it.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Omar Qureshi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # foobara-aws
2
+
3
+ Deploy [Foobara](https://github.com/foobara) commands to AWS Lambda, with the
4
+ topology read out of the manifest instead of restated in infrastructure code.
5
+
6
+ `connect(Posts)` already says what a deployment unit is. The manifest already
7
+ says which commands it holds, where they are served, and which need
8
+ authentication. This reads that and builds the AWS resources.
9
+
10
+ ```ruby
11
+ plan = Foobara::AWS.plan(JSON.parse(File.read("build/plan.json")))
12
+
13
+ service = Foobara::AWS::CDK::Service.new(self, "Api", plan: plan, code_root: "build")
14
+
15
+ posts_table.grant_read_write_data(service.function("posts"))
16
+ ```
17
+
18
+ ## Two halves, on purpose
19
+
20
+ ```ruby
21
+ Foobara::AWS.plan(manifest) # what to deploy. Plain data. No CDK, no Foobara.
22
+ Foobara::AWS::CDK::Service.new # the AWS resources. No application.
23
+ ```
24
+
25
+ They are separable because they usually run in different places. The plan is
26
+ produced where the app is — a running connector serves `/manifest` — and
27
+ consumed where the infrastructure is, which is often a CDK app that should not
28
+ have to load an application in order to deploy it.
29
+
30
+ A plan survives a JSON round trip, so it travels as a build artifact:
31
+
32
+ ```ruby
33
+ # in the build, against a running connector
34
+ File.write("build/plan.json", JSON.pretty_generate(Foobara::AWS.plan(manifest).to_h))
35
+
36
+ # in the CDK app — no Foobara, no ORM, no application gems
37
+ Foobara::AWS::CDK::Service.new(self, "Api",
38
+ plan: Foobara::AWS::Plan.load(JSON.parse(File.read("build/plan.json"))),
39
+ code_root: "build")
40
+ ```
41
+
42
+ That also gets you a property worth having: synthesis depends on what was
43
+ actually built, so `cdk synth` cannot create a route to a Lambda whose code is
44
+ missing. `Service` raises if a unit's artifact directory is not there.
45
+
46
+ ## What the plan reads
47
+
48
+ | plan | manifest field |
49
+ | --- | --- |
50
+ | unit identity and grouping | `domain` or `organization` |
51
+ | the route | `mount` + `scoped_full_path` |
52
+ | which commands are public | `requires_authentication` — **derived, never handed in** |
53
+ | how to size the function | `aws_lambda` (see below) |
54
+
55
+ The public list is the one to notice. It is not a list you maintain: a command
56
+ moves in or out of it by how it is connected, and nowhere else.
57
+
58
+ ## Granularity
59
+
60
+ ```ruby
61
+ Foobara::AWS.plan(manifest) # one Lambda per domain
62
+ Foobara::AWS.plan(manifest, granularity: :organization) # one per organization
63
+ Foobara::AWS.plan(manifest, granularity: :command) # one per command
64
+ ```
65
+
66
+ Foobara gives two natural grouping levels where most frameworks give one, so
67
+ both are offered, plus the fine-grained case. Per-domain and per-organization
68
+ units get a greedy route (`/run/Posts/{proxy+}`); per-command units get an exact
69
+ one.
70
+
71
+ A greedy route is only safe with a REQUEST authorizer, which sees the path and
72
+ can decide per command. A JWT authorizer attaches per route, so a unit mixing
73
+ public and authenticated commands would have to be split into one route each.
74
+
75
+ ## Sizing: `aws_lambda`
76
+
77
+ The one thing a manifest cannot otherwise supply. Foobara describes what a
78
+ command *is*, not how it should be run — but something has to carry it, and the
79
+ person who knows a command fans out across a whole comment tree is the person
80
+ writing that command:
81
+
82
+ ```ruby
83
+ class DestroyPost < Foobara::Command
84
+ extend Foobara::AWS::Lambda
85
+ aws_lambda vcpu: 1, timeout: 60
86
+ ...
87
+ end
88
+ ```
89
+
90
+ No change to Foobara is required: a command's manifest is `super.merge(...)`, so
91
+ this adds a key and the connector serves it.
92
+
93
+ **`vcpu:` resolves to memory.** Lambda has no CPU setting — CPU is allocated in
94
+ proportion to memory, and 1,769 MB is where a function gets one full vCPU. So
95
+ asking for compute is more honest than picking a memory number, but it is not a
96
+ second dial. Give both and the larger memory wins.
97
+
98
+ Where a unit holds several commands, the largest value any of them asked for
99
+ wins: a unit runs all of its commands in one function, so it must be sized for
100
+ the hungriest. Undersizing is a runtime failure; oversizing is a rounding error
101
+ on the bill.
102
+
103
+ ## The authorizer
104
+
105
+ Pass a built authorizer, or a hash and let `Service` build it:
106
+
107
+ ```ruby
108
+ Foobara::AWS::CDK::Service.new(self, "Api", plan: plan, code_root: "build",
109
+ authorizer: { code: "build/authorizer", environment: { "ISSUER" => issuer } })
110
+ ```
111
+
112
+ Building it here is deliberate, because it is the only way to guarantee this:
113
+
114
+ **No identity source, and caching off.** Naming an identity source makes it
115
+ *required* — when the header is absent, API Gateway answers 401 itself and never
116
+ invokes the authorizer. Every anonymous caller is refused before any "is this
117
+ command public?" logic can run, which defeats the only reason to choose a
118
+ REQUEST authorizer over a JWT one. It is an easy mistake to make and a hard one
119
+ to see: the symptom is that public commands 401 for signed-out callers, with no
120
+ log line anywhere, because the function was never called.
121
+
122
+ The plan's public list and mount are passed to the function as
123
+ `FOOBARA_ANONYMOUS` and `FOOBARA_MOUNT`, so the authorizer decides per command
124
+ from `rawPath` without being configured separately.
125
+
126
+ `Service` does not implement the authorizer itself — verification is
127
+ identity-provider-specific, and `code:` is your own artifact.
128
+
129
+ ## Packaging
130
+
131
+ ```ruby
132
+ Foobara::AWS::Packager.new(plan: plan, root: ".", authorizer: {}).build
133
+ ```
134
+
135
+ One artifact per unit: the application's sources, a generated `handler.rb`, and
136
+ a standalone gem bundle holding only that unit's dependencies (`units/<name>.gemfile`).
137
+ Units whose gemfiles resolve identically share one bundle build.
138
+
139
+ The generated handler is fully generic — everything unit-specific comes from the
140
+ plan, and everything app-specific from the boot file, where the application sets
141
+ `Foobara::AWS.caller_builder`. Pass `handler_template:` for your own.
142
+
143
+ Two things it handles that are easy to get wrong:
144
+
145
+ - **Standalone, not `bundle exec`.** Bundler's runtime is a large fraction of a
146
+ Ruby cold start and buys a deployed artifact nothing.
147
+ - **Gems declared by `path:`.** Bundler does not copy those into a standalone
148
+ bundle; it writes their location into the load path, relative to the bundle
149
+ directory. Copy the bundle into an artifact and that path resolves elsewhere,
150
+ so the unit boots on the build machine and dies in Lambda. They are copied in
151
+ and the load path rewritten. (Use `mounts:` so the build container can see
152
+ them in the first place.)
153
+
154
+ ## What it does not do
155
+
156
+ - **Create tables, buckets or queues.** Those are the application's, not the
157
+ connector's. For DynamoDB from Dynamoid models, see
158
+ [dynamoid-cdk-schema](https://github.com/omarqureshi/dynamoid-cdk-schema),
159
+ which follows the same describe/build split.
160
+
161
+ ## Development
162
+
163
+ ```sh
164
+ bin/setup
165
+ bundle exec rspec
166
+ bundle exec rubocop
167
+ ```
168
+
169
+ The specs use a manifest fragment carrying only the fields planning reads — no
170
+ type declarations, no possible errors — which is a check in itself that nothing
171
+ else is needed.
172
+
173
+ ## License
174
+
175
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ # An API Gateway v2 REQUEST authorizer that supports OPTIONAL authentication.
4
+ #
5
+ # RUNTIME, not synth. Require this file and nothing else from the gem — it loads
6
+ # no planning code and no CDK. It runs on EVERY request, including anonymous
7
+ # ones, so its cold start is the one that matters most.
8
+ #
9
+ # require "foobara/aws/authorizer"
10
+ #
11
+ # HANDLER = Foobara::AWS::Authorizer.from_env(issuer: ENV.fetch("ISSUER"),
12
+ # audience: ENV.fetch("AUDIENCE"))
13
+ # def handle(event:, context:) = HANDLER.call(event, context)
14
+ #
15
+ # The public list and the mount come from the environment — FOOBARA_ANONYMOUS
16
+ # and FOOBARA_MOUNT, which Foobara::AWS::CDK::Service sets from the plan. So the
17
+ # set of commands reachable without authentication is derived from
18
+ # `requires_authentication` and never maintained by hand.
19
+ #
20
+ # WHY IT EXISTS. An API Gateway JWT authorizer is all-or-nothing: it rejects a
21
+ # request with no token, and a route without one receives no verified claims at
22
+ # all. There is no "verify if present". That breaks any command which is public
23
+ # but viewer-aware — one that marks your own posts editable, say. Such a command
24
+ # must be readable signed out AND must know who is calling when someone is
25
+ # signed in.
26
+ #
27
+ # This sees rawPath, so it decides per COMMAND rather than per route. Routes stay
28
+ # greedy, which keeps the route count off API Gateway's per-API quota, and the
29
+ # public list is configuration rather than topology.
30
+ #
31
+ # IDENTITY SOURCE. The stack must NOT declare one — Service guarantees this, and
32
+ # it is the reason Service builds the authorizer rather than accepting one.
33
+ # Naming an identity source makes it required: when the header is absent API
34
+ # Gateway answers 401 itself and never invokes this function, so every anonymous
35
+ # caller is refused before the logic below can allow them.
36
+ #
37
+ # It does NOT decide what a caller may do. It answers "is this token real, and is
38
+ # this command reachable without one" — authorization is the command's own,
39
+ # through Foobara's allowed_rule.
40
+ #
41
+ # Verification covers signature (RS256 against the issuer's JWKS), exp, iss and
42
+ # aud. JWKS is fetched once per execution environment — cold-start scope, never
43
+ # request scope.
44
+
45
+ require "json"
46
+ require "net/http"
47
+ require "uri"
48
+
49
+ require_relative "error"
50
+ require_relative "version"
51
+
52
+ module Foobara
53
+ module AWS
54
+ class Unverified < StandardError; end
55
+
56
+ class Authorizer
57
+ # Reads the plan-derived settings Service puts in the environment, so a
58
+ # deployed authorizer needs no configuration of its own beyond who issued
59
+ # the tokens.
60
+ def self.from_env(issuer:, audience:, jwks_url: nil)
61
+ new(
62
+ issuer:, audience:, jwks_url:,
63
+ anonymous: ENV.fetch("FOOBARA_ANONYMOUS", "").split(","),
64
+ mount: ENV.fetch("FOOBARA_MOUNT", "/run")
65
+ )
66
+ end
67
+
68
+ def initialize(issuer:, audience:, anonymous: [], mount: "/run", jwks_url: nil)
69
+ @issuer = issuer.to_s.chomp("/")
70
+ @audience = Array(audience)
71
+ # Foobara's own full command names — "Posts::ListPosts" — so the list the
72
+ # packager derives from the manifest travels here untranslated.
73
+ @anonymous = Array(anonymous).map(&:to_s)
74
+ @mount = mount
75
+ @jwks_url = jwks_url || "#{@issuer}/.well-known/jwks.json"
76
+ end
77
+
78
+ # SIMPLE response format:
79
+ # { "isAuthorized" => bool, "context" => { ...claims } }
80
+ # Anything in `context` reaches the command Lambda at
81
+ # requestContext.authorizer.lambda, which is where the handler reads the
82
+ # viewer from.
83
+ def call(event, _lambda_context = nil)
84
+ token = bearer(event)
85
+ command = command_name(event)
86
+
87
+ if token.nil?
88
+ # No token at all. Allowed only where the command is declared public —
89
+ # and it reaches the command with no claims, so a public viewer-aware
90
+ # command correctly sees an anonymous caller.
91
+ return allow({}) if anonymous?(command)
92
+
93
+ return deny
94
+ end
95
+
96
+ begin
97
+ allow(claims_from(token))
98
+ rescue Unverified
99
+ # A present-but-invalid token: expired, wrong audience, bad signature.
100
+ #
101
+ # On a public command this is treated as anonymous rather than refused —
102
+ # someone whose session expired should still see the public feed, and
103
+ # they gain nothing by it. On a gated command it is a refusal.
104
+ anonymous?(command) ? allow({}) : deny
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def allow(context) = { "isAuthorized" => true, "context" => stringify(context) }
111
+ def deny = { "isAuthorized" => false }
112
+
113
+ # API Gateway forwards only strings in the authorizer context.
114
+ def stringify(claims)
115
+ claims.to_h { |k, v| [k.to_s, v.is_a?(Array) ? v.join(",") : v.to_s] }
116
+ end
117
+
118
+ def anonymous?(command) = command && @anonymous.include?(command)
119
+
120
+ # "/run/Posts/ListPosts" -> "Posts::ListPosts", which is exactly the key the
121
+ # Foobara manifest files that command under. Deciding here rather than in
122
+ # routing is what lets routes stay greedy.
123
+ def command_name(event)
124
+ path = event["rawPath"] || event.dig("requestContext", "http", "path") || ""
125
+ parts = path.delete_prefix("#{@mount}/").split("/")
126
+ parts.length == 2 ? parts.join("::") : nil
127
+ end
128
+
129
+ def bearer(event)
130
+ headers = event["headers"] || {}
131
+ raw = headers["authorization"] ||
132
+ headers.find { |k, _| k.to_s.downcase == "authorization" }&.last
133
+ return nil if raw.nil? || raw.empty?
134
+
135
+ raw.to_s.sub(/\Abearer\s+/i, "").strip.then { |t| t.empty? ? nil : t }
136
+ end
137
+
138
+ def claims_from(token)
139
+ require_jwt!
140
+
141
+ payload, = JWT.decode(
142
+ token, nil, true,
143
+ algorithms: ["RS256"],
144
+ jwks: jwks,
145
+ iss: @issuer, verify_iss: true,
146
+ aud: @audience, verify_aud: !@audience.empty?,
147
+ verify_expiration: true
148
+ )
149
+ payload
150
+ rescue Unverified
151
+ raise # an unreachable JWKS is already the right failure
152
+ rescue StandardError => e
153
+ # Broad on purpose: the jwt gem raises several unrelated classes for
154
+ # signature, key-lookup and claim failures, and every one of them means the
155
+ # same thing here — this token is not trustworthy.
156
+ raise Unverified, "#{e.class}: #{e.message}"
157
+ end
158
+
159
+ # `jwt` is deliberately not a dependency of this gem: only the authorizer
160
+ # needs it, only the authorizer's own deployment unit installs it, and a
161
+ # bare LoadError — which is not even a StandardError, so it escapes the
162
+ # rescue above — would say none of that.
163
+ def require_jwt!
164
+ require "jwt"
165
+ rescue LoadError
166
+ raise Error,
167
+ "Foobara::AWS::Authorizer needs the `jwt` gem, which foobara-aws does not depend on — " \
168
+ "only the authorizer uses it. Add `gem \"jwt\"` to the authorizer unit's gemfile."
169
+ end
170
+
171
+ # Cold-start scope: fetched once per execution environment and reused across
172
+ # invocations. Nothing request-specific may be cached here.
173
+ def jwks
174
+ @jwks ||= begin
175
+ JSON.parse(Net::HTTP.get(URI(@jwks_url)), symbolize_names: true)
176
+ rescue StandardError => e
177
+ # An unreachable JWKS must not quietly become "everyone is anonymous".
178
+ raise Unverified, "could not fetch JWKS from #{@jwks_url}: #{e.message}"
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../error"
4
+ # Service takes a Plan and every caller has to build one, so requiring
5
+ # "foobara/aws/cdk/service" alone must be enough.
6
+ require_relative "../plan"
7
+
8
+ module Foobara
9
+ module AWS
10
+ # Everything here is SYNTH time. Nothing under Foobara::AWS::CDK is
11
+ # ever loaded inside a Lambda — that is why the handler and the
12
+ # authorizer live outside it.
13
+ module CDK
14
+ # Synthesises a {Plan} into Lambda functions behind one HTTP API.
15
+ #
16
+ # AWS CDK is the caller's dependency (the Ruby CDK, from its preview feed),
17
+ # so it is referenced lazily — {Foobara::AWS.plan} works without it, which is
18
+ # what lets a build step produce a plan on a machine that has no CDK at all.
19
+ #
20
+ # service = Foobara::AWS::CDK::Service.new(self, "Api", plan: plan, code_root: "build")
21
+ # table.grant_read_write_data(service.function("posts"))
22
+ #
23
+ # It does NOT build artifacts. `code_root` must already contain one directory
24
+ # per unit, each with a `handler.rb` — so synthesis cannot create a route to a
25
+ # Lambda whose code was never built.
26
+ class Service
27
+ DEFAULT_SIZING = { memory_size: 1024, timeout_seconds: 10 }.freeze
28
+
29
+ attr_reader :api, :functions, :plan
30
+
31
+ # rubocop:disable Metrics/ParameterLists
32
+ def initialize(scope, id, plan:, code_root:, sizing: {}, defaults: DEFAULT_SIZING,
33
+ environment: {}, runtime: nil, architecture: nil, authorizer: nil,
34
+ api_props: {}, tracing: nil, log_retention: nil)
35
+ # Checked first: everything below needs it, and a bare LoadError from
36
+ # `constructs` would say nothing about which dependency is missing.
37
+ cdk
38
+
39
+ @scope = construct(scope, id)
40
+ @plan = plan
41
+ @code_root = code_root
42
+ @sizing = sizing.transform_keys(&:to_s)
43
+ @defaults = DEFAULT_SIZING.merge(defaults)
44
+ @environment = environment
45
+ @runtime = runtime
46
+ @architecture = architecture
47
+ @tracing = tracing
48
+ @log_retention = log_retention
49
+ @functions = {}
50
+
51
+ @api = cdk::APIGatewayv2::HttpAPI.new(@scope, "Api", api_props)
52
+ @authorizer = build_authorizer(authorizer)
53
+ plan.units.each { |unit| add_unit(unit) }
54
+ end
55
+ # rubocop:enable Metrics/ParameterLists
56
+
57
+ # Look a unit's function up by name, for grants:
58
+ # table.grant_read_write_data(service.function("posts"))
59
+ def function(name) = @functions.fetch(name.to_s)
60
+
61
+ def url = @api.url
62
+
63
+ private
64
+
65
+ def add_unit(unit)
66
+ fn = build_function(unit)
67
+ @functions[unit.name] = fn
68
+
69
+ props = {
70
+ path: unit.route,
71
+ methods: [cdk::APIGatewayv2::HttpMethod::ANY],
72
+ integration: cdk::APIGatewayv2Integrations::HttpLambdaIntegration.new(
73
+ "#{logical(unit.name)}Integration", fn
74
+ )
75
+ }
76
+ props[:authorizer] = @authorizer if @authorizer
77
+ @api.add_routes(props)
78
+ end
79
+
80
+ def build_function(unit)
81
+ # Precedence: an explicit sizing: override, then whatever the unit's
82
+ # commands declared via aws_lambda, then the defaults. The override
83
+ # exists for the case the declaration cannot know about — an
84
+ # environment where everything should be small, say.
85
+ sizing = @defaults.merge(unit.sizing).merge(@sizing.fetch(unit.name, {}))
86
+ asset = File.join(@code_root, unit.name)
87
+ unless Dir.exist?(asset)
88
+ raise Error, "no artifact for unit #{unit.name.inspect} at #{asset} — build it before synthesising"
89
+ end
90
+
91
+ cdk::Lambda::Function.new(@scope, logical(unit.name), {
92
+ runtime: @runtime || cdk::Lambda::Runtime.RUBY_4_0,
93
+ # x86_64 by default: building arm64 artifacts on an x86_64 host
94
+ # runs under qemu, which is dramatically slower.
95
+ architecture: @architecture || cdk::Lambda::Architecture.X86_64,
96
+ handler: "handler.handle",
97
+ code: cdk::Lambda::Code.from_asset(asset),
98
+ memory_size: sizing.fetch(:memory_size),
99
+ timeout: cdk::Duration.seconds(sizing.fetch(:timeout_seconds)),
100
+ environment: @environment.merge("FOOBARA_UNIT" => unit.name)
101
+ }.merge(observability))
102
+ end
103
+
104
+ # Accepts an already-built authorizer, or a hash describing one to build.
105
+ #
106
+ # Building it here rather than leaving it to the caller is deliberate: it
107
+ # is the only way to guarantee the two settings below, and getting either
108
+ # wrong silently breaks optional authentication rather than failing.
109
+ def build_authorizer(config)
110
+ return nil if config.nil?
111
+ return config unless config.is_a?(Hash)
112
+
113
+ fn = authorizer_function(config)
114
+
115
+ cdk::APIGatewayv2Authorizers::HttpLambdaAuthorizer.new(
116
+ config.fetch(:id, "FoobaraAuth"), fn,
117
+ {
118
+ response_types: [cdk::APIGatewayv2Authorizers::HttpLambdaResponseType::SIMPLE],
119
+ # NO identity source, and this is not an oversight.
120
+ #
121
+ # Naming one makes it REQUIRED: when the header is absent, API
122
+ # Gateway answers 401 itself and never invokes the authorizer. Every
123
+ # anonymous caller is then refused before any "is this command
124
+ # public?" logic can run — which defeats the only reason to choose a
125
+ # REQUEST authorizer over a JWT one.
126
+ #
127
+ # With no identity source the authorizer runs on every request, which
128
+ # is what optional means. Caching must then be off: there is no key
129
+ # to cache under.
130
+ identity_source: [],
131
+ results_cache_ttl: cdk::Duration.seconds(0)
132
+ }
133
+ )
134
+ end
135
+
136
+ def authorizer_function(config)
137
+ asset = config.fetch(:code)
138
+ # The public list is passed in, not configured: it comes from the plan,
139
+ # which read it from the manifest.
140
+ env = { "FOOBARA_ANONYMOUS" => plan.anonymous.join(","),
141
+ "FOOBARA_MOUNT" => plan.mount }.merge(config.fetch(:environment, {}))
142
+
143
+ cdk::Lambda::Function.new(@scope, config.fetch(:id, "FoobaraAuth"), {
144
+ runtime: @runtime || cdk::Lambda::Runtime.RUBY_4_0,
145
+ architecture: @architecture || cdk::Lambda::Architecture.X86_64,
146
+ handler: config.fetch(:handler, "handler.handle"),
147
+ code: cdk::Lambda::Code.from_asset(asset),
148
+ memory_size: config.fetch(:memory_size, 512),
149
+ timeout: cdk::Duration.seconds(config.fetch(:timeout_seconds, 10)),
150
+ environment: env
151
+ }.merge(observability))
152
+ end
153
+
154
+ # Tracing and log retention, applied identically to every function
155
+ # including the authorizer — a trace that stops at the authorizer would
156
+ # hide the one invocation that happens on every single request.
157
+ #
158
+ # `tracing: :active` is the cold-start measure worth having: X-Ray breaks
159
+ # a cold invocation into an Initialization subsegment and the handler's
160
+ # own work, so you can see what the boot actually costs rather than
161
+ # inferring it. CloudWatch's REPORT line already carries Init Duration
162
+ # per invocation; X-Ray adds the shape of it, and the view across
163
+ # services once a command calls another.
164
+ #
165
+ # NOTE it is Lambda-side only. An API Gateway HTTP API (v2) does not
166
+ # support X-Ray — only REST APIs do — so the trace begins at the
167
+ # function, not at the edge.
168
+ def observability
169
+ props = {}
170
+ props[:tracing] = tracing_mode(@tracing) if @tracing
171
+ props[:log_retention] = @log_retention if @log_retention
172
+ props
173
+ end
174
+
175
+ def tracing_mode(value)
176
+ case value
177
+ when :active then cdk::Lambda::Tracing::ACTIVE
178
+ when :pass_through then cdk::Lambda::Tracing::PASS_THROUGH
179
+ when :disabled then cdk::Lambda::Tracing::DISABLED
180
+ else value # an already-resolved CDK enum
181
+ end
182
+ end
183
+
184
+ # A child construct, so the functions' logical ids cannot collide with
185
+ # anything at stack level — a "comments" function and a "Comments" table
186
+ # both want the id "Comments" otherwise, and synthesis fails.
187
+ def construct(scope, id)
188
+ require "constructs"
189
+ Constructs::Construct.new(scope, id)
190
+ end
191
+
192
+ # CloudFormation logical ids must be alphanumeric.
193
+ def logical(name) = name.to_s.split(/[.\-_]/).map(&:capitalize).join
194
+
195
+ def cdk
196
+ return AWSCDK if defined?(AWSCDK)
197
+
198
+ raise Error, "aws-cdk-lib must be loaded to synthesise — add it to your CDK app and `require \"aws-cdk-lib\"`"
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foobara
4
+ module AWS
5
+ # Shared by every entry point — planning, packaging, synthesis, runtime — so
6
+ # each can be required on its own without dragging the others in.
7
+ class Error < StandardError; end
8
+ end
9
+ end