Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
2ca16f5
docs: record edge rebuild plan and gateway identity proposal
ajbeck Sep 14, 2026
efd1ad7
docs: accept gateway identity policy and propose adapter boundary
ajbeck Sep 14, 2026
e3a0125
feat(edge): add validated adapter construction
ajbeck Sep 14, 2026
fc65284
docs: accept typed event entry points and AWS type reuse
ajbeck Sep 14, 2026
40d9181
feat(edge): decode gateway events with JSON v2 and AWS types
ajbeck Sep 14, 2026
cea8263
feat(edge): translate gateway events into HTTP requests
ajbeck Sep 14, 2026
d940479
feat(edge): bound response encoding and unify invocation lifetime
ajbeck Sep 14, 2026
bdb429c
docs: qualify metadata policy and propose REST response streaming
ajbeck Sep 14, 2026
02c9f03
docs: specify streaming boundaries and probe SDK transport
ajbeck Sep 14, 2026
a4726dc
docs: verify latest stable SDK and gateway streaming contract
ajbeck Sep 14, 2026
53a98be
Implement private streaming bridge and invocation ownership
ajbeck Sep 14, 2026
931aa60
Propose shared response header policy and streaming prefix limits
ajbeck Sep 14, 2026
722bef5
Clarify payload terminology and propose consumer header processing
ajbeck Sep 14, 2026
f0965f7
Specify recommended action header selector and middleware contracts
ajbeck Sep 14, 2026
3d83d44
docs: refine header API proposal after Go 1.27 review
ajbeck Sep 15, 2026
78eddbb
feat: add strict action header selection for HTTP consumers
ajbeck Sep 15, 2026
16767b3
docs: measure header snapshot costs and refine budget proposal
ajbeck Sep 15, 2026
93eb51c
feat: implement bounded response headers and streaming metadata
ajbeck Sep 15, 2026
b7e8637
feat: implement buffered HTTP response writer and finalization
ajbeck Sep 15, 2026
83085f2
docs: propose shared identity and invocation error contracts
ajbeck Sep 15, 2026
79eb170
feat: classify invocation failures with sanitized public errors
ajbeck Sep 15, 2026
383753d
docs: propose claims API with measured resource limits
ajbeck Sep 16, 2026
d308826
feat: implement owned claims with bounded direct JSON processing
ajbeck Sep 16, 2026
71e82ab
docs: accept IAM proof authentication and exported client helper
ajbeck Sep 16, 2026
d46ba1d
docs: propose IAM proof protocol and verify SDK reconstruction
ajbeck Sep 16, 2026
1ef82e5
feat: add immutable callers and context isolation
ajbeck Sep 16, 2026
50b3e34
feat: wire buffered Lambda adapter and native gateway identity
ajbeck Sep 16, 2026
97b9cf7
feat: add audience-bound IAM proof generation and verification
ajbeck Sep 16, 2026
fcb2a21
docs: propose HTTP authentication selector and challenges
ajbeck Sep 16, 2026
40b31ba
feat: add explicit HTTP authentication selection and middleware
ajbeck Sep 16, 2026
7a81fcb
docs: propose Cognito verification and JWKS cache design
ajbeck Sep 16, 2026
9d9b690
feat: verify Cognito access tokens with bounded JWKS caching
ajbeck Sep 16, 2026
eb0661b
docs: propose application authorization rules and dispatch boundaries
ajbeck Sep 16, 2026
798d24f
feat(authz): add exact authorization rules and dispatcher examples
ajbeck Sep 17, 2026
24244ce
docs: review streaming writer policies and SDK compatibility
ajbeck Sep 17, 2026
7f481e9
feat: implement REST response streaming adapter
ajbeck Sep 17, 2026
5400066
docs: verify SDK streaming contract and prepare AWS questions
ajbeck Sep 17, 2026
96a9552
docs: propose consumer-readiness milestone
ajbeck Sep 20, 2026
dd9fd80
deps: update smithy-go to v1.28.2
ajbeck Sep 21, 2026
230367c
feat: add complete dispatcher consumer example
ajbeck Sep 21, 2026
a3587bf
ci: target main pull requests and document release automation
ajbeck Sep 21, 2026
6eb3167
ci: configure release-please with GitHub App authentication
ajbeck Sep 21, 2026
f6a2624
ci: pin workflow actions to latest stable releases
ajbeck Sep 21, 2026
f45c70d
docs: refresh CI and release readiness evidence
ajbeck Sep 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Go

on:
workflow_call:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]

concurrency:
group: go-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
go: ['1.27.0', '1.27.x']
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ matrix.go }}
cache-dependency-path: go.sum
- name: Check formatting
run: test -z "$(gofmt -l .)"
- name: Test with race detection
run: go test -race ./...
- name: Vet
run: go vet ./...
- name: Verify modules
run: go mod verify
- name: Check external consumer
run: sh scripts/check-consumer.sh

lambda-build:
runs-on: ubuntu-latest
strategy:
matrix:
arch: [arm64, amd64]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.27.x'
cache-dependency-path: go.sum
- name: Build for Lambda
env:
GOOS: linux
GOARCH: ${{ matrix.arch }}
CGO_ENABLED: '0'
run: go build ./...
50 changes: 50 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Release Please

on:
push:
branches: [main]

permissions:
contents: read

concurrency:
group: release-please-${{ github.ref }}
cancel-in-progress: false

jobs:
validate:
uses: ./.github/workflows/go.yml

release:
needs: validate
runs-on: ubuntu-latest
# Repository writes use the scoped App token, not GITHUB_TOKEN.
permissions: {}
steps:
- name: Check App configuration
env:
APP_CLIENT_ID: ${{ vars.RELEASE_PLEASE_CLIENT_ID }}
APP_PRIVATE_KEY: ${{ secrets.RELEASE_PLEASE_PRIVATE_KEY }}
run: |
if [ -z "$APP_CLIENT_ID" ] || [ -z "$APP_PRIVATE_KEY" ]; then
echo '::error::Set RELEASE_PLEASE_CLIENT_ID and RELEASE_PLEASE_PRIVATE_KEY; see docs/releases.md.'
exit 1
fi
- name: Create repository-scoped App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.RELEASE_PLEASE_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_PLEASE_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
permission-issues: write
permission-pull-requests: write
- name: Update release PR or publish merged release
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
with:
token: ${{ steps.app-token.outputs.token }}
target-branch: main
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
1 change: 1 addition & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
42 changes: 42 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Working agreement

Build a new Go module from first principles. Beakley is reference material for
requirements and failure cases, not a source to copy and update.

The planned root package is `edge`. Target Go 1.27 from the start and use
`encoding/json/v2` directly for JSON handled by this module.

Read [the implementation plan](docs/plan.md) and relevant
[decision records](docs/decisions/README.md) before work. Preserve the distinction
between accepted direction, proposed API details, and implemented behavior.

## Decisions and user review

When a substantive question, design decision, or issue arises:

1. Inspect our code and tests and consult the relevant official documentation.
Route AWS knowledge lookups through `aws-proxy-public`; do not use account
APIs for documentation research. Use official Go documentation and the
supplied Go skills when applicable.
2. Record the evidence, alternatives, recommendation, reasoning, and consequences
in a decision record. Label unverified assumptions and open questions.
3. Present the question or issue and the recommendation to the user for review.
4. Await the user's approval or redirection before implementing the dependent
behavior. Continue useful independent work within the accepted plan.
5. Record the user's resolution and update the plan before implementing it.

Do not interpret silence as approval. Routine execution within an approved
decision does not require another approval. Do not silently settle public API,
trust-boundary, compatibility, or error-policy decisions.

## Implementation and validation

- Work on a working branch, not `main`.
- Keep transport, identity, authentication, and authorization responsibilities
distinct. Keep `identity` free of AWS dependencies.
- Preserve denied-by-default authorization and validate identity invariants.
- Test external contracts rather than mirroring implementation details.
- Use recorded/synthetic events and local test servers; do not deploy resources
or invoke live AWS services merely to run unit tests.
- Keep documentation and decision status accurate as work progresses.
- Do not copy Beakley source or tests into this repository.
146 changes: 146 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# go-lambda-edge

A Go 1.27 module being built to serve AWS API Gateway requests through ordinary
`net/http` handlers in AWS Lambda, with companion identity, authentication, and
authorization packages.

The root package is `edge`. The buffered adapter is implemented for REST proxy,
HTTP API payload 1.0 and HTTP API payload 2.0. It uses AWS Lambda Go v1.55.0 types
and direct encoding/json/v2 processing. Raw and already typed entry points share
HTTP conversion, response capture, identity policy, cancellation and cleanup.

Start with the [complete orders dispatcher](examples/dispatcher/README.md) for
verifier wiring, application grants, JSON errors and three transport entry points.
The [support matrix](docs/support.md) distinguishes implemented behavior, deferred
scope and deployment qualifications; the [testing guide](docs/consumer-testing.md)
shows how to test each consumer boundary.

Create an adapter with `edge.New(handler, options...)`, handle the constructor
error, then register it with `lambda.Start(adapter)`. Pass the object, not
`adapter.Invoke`, to retain the raw JSON v2 boundary. For a fixed payload family,
use `lambda.Start(adapter.HandleV1)` or `lambda.Start(adapter.HandleV2)`; these
delegate envelope JSON to the SDK and do no envelope serialization inside edge.
See the [runnable registration examples](adapter_example_test.go).

Gateway identity is disabled by default. `edge.WithGatewayIdentity(true)` enables
the reviewed native IAM and JWT/Cognito field mappings. Invalid, conflicting or
unsupported assertions fail before the handler; custom-authorizer mapping remains
unimplemented. Authorization headers are not independently authenticated. See the
[fixture coverage and fidelity qualifications](docs/gateway-fixtures.md).

The shared invocation error contract is implemented across those private stages:
public categories support `errors.Is`, and `errors.AsType[*edge.InvocationError]`
exposes sanitized operation and resource-limit diagnostics. HTTP error responses
remain successful transport outcomes; operation, cleanup, cancellation, and late
stream failures retain their accepted ownership and precedence. The shared
claims API, caller/context construction, JWT normalization and resource limits
are implemented in `identity`. Every invocation rejects an inherited caller,
including when gateway identity is disabled. Constructors themselves do not
authenticate credentials or authorize application actions.

The accepted authentication direction supports IAM credentials or an OAuth
bearer token on the same route without built-in `AWS_IAM` authorization. IAM
clients use `iamproof.NewGenerator` to generate a signed STS GetCallerIdentity
proof; `iamproof.NewVerifier` submits that client-signed request to AWS and returns
a verified IAM caller. Both are implemented using the latest stable SDK core
v1.47.0 and STS v1.51.0, checked on 2026-09-16. See the
[IAM proof guide](docs/iamproof.md) and [runnable examples](iamproof/example_test.go).
The shared `authn` selector and required-authentication middleware are implemented.
Configure Bearer and/or IAM-proof verifier functions; the
[authentication guide](docs/authn.md) shows the required IAM error mapping and
the custom dispatcher/HTTP response boundaries. `authn.NewCognitoVerifier` now
provides RS256 access-token verification with direct JSON v2, explicit issuer/
client/resource restrictions and bounded JWKS caching; see the
[Cognito guide](docs/cognito.md). Wire its Verify method as the Bearer verifier.
The `authz` package now provides exact IAM/JWT predicates, ordered All/Any rules
and context-aware application grant checks. Consumers own their action registry,
resource selection and HTTP responses; see the [authorization guide](docs/authz.md)
and [runnable dispatcher examples](authz/example_test.go).
No live Cognito/STS interoperability test has run.

REST response streaming is implemented through `edge.NewStreaming(handler)` and
`lambda.Start(adapter.Handle)` or `lambda.Start(adapter.HandleV1)`. It provides
bounded sniffing, Flush/FlushError, raw body delivery, backpressure, cancellation,
cleanup and sanitized terminal errors. Streaming never infers Content-Length.
See the [streaming guide](docs/streaming.md) and
[SSE, NDJSON, binary and gzip examples](streaming_example_test.go).
Local SDK tests pass, but deployment support remains unverified: SDK v1.55.0's
Runtime API mode header and connection handling differ from current AWS guidance.
The [deployment qualification](docs/decisions/0022-streaming-writer-review.md#s3-sdk-issue-retain-the-existing-deployment-release-gate)
remains open; no live API Gateway streaming test has run.

## Header-selected actions

`edge.NewActionHeader("Action")` creates an immutable selector whose
`Parse(r.Header)` method returns an action string or an error. Configure the
header name explicitly. Selection preserves case, trims outer spaces/tabs, and
requires a nonempty HTTP token. Repeated values and comma-bearing values are
rejected, including Gateway payload 2.0's comma-combined duplicates. Use
`errors.Is` with `edge.ErrActionMissing`, `edge.ErrActionAmbiguous`, or
`edge.ErrActionInvalid` to distinguish request-input failures.

Use ordinary `http.Handler` middleware to process this and other custom headers.
The consumer owns its action registry, metadata, authorization and HTTP error
responses. Select once, authorize that selection, then execute it; do not select
again from mutable headers. An action header does not authenticate a caller.
The selector works with both ordinary Go HTTP servers and the Lambda adapter.

See the [runnable middleware example](action_example_test.go) and
[accepted header contract](docs/decisions/0011-request-header-processing.md).

## Response header budget

Response headers have a 256 KiB weighted resource budget, configurable with
`edge.WithResponseHeaderBudget(bytes)` from 1 byte through 6 MiB. Each original
value costs `len(name) + len(value) + 32`; nil/empty slices still cost a name plus
32, and generated headers consume remaining budget. This is separate from the
complete buffered-envelope limit and the 16,000-byte streaming metadata prefix
(including its delimiter). It is neither an AWS quota nor a total heap bound.
Both writers apply the allowance at commitment. Raw buffered invocation also
checks the complete serialized response against 6 MiB; typed callers own their
final envelope serialization and size. Streaming checks the complete metadata
prefix before handing off the reader; its body does not use the buffered limit.

## Owned claims

`identity.NewJWT` and `identity.NewIAM` construct immutable, mutually exclusive
caller views. Zero Caller is anonymous. Constructors validate facts and source
attribution; they do not authenticate credentials or grant permissions.
JWT issuer, subject, client ID, audience, scopes and Cognito groups stay distinct.
Ambiguous gateway collections remain unavailable, while conflicting interpretable
scope sets fail. IAM callers retain exact ARN paths and session names.

`identity.WithCaller` installs one caller in a derived context and rejects
replacement, even by an apparently identical caller. `identity.FromContext`
returns anonymous when absent. See the [caller examples](identity/caller_example_test.go).

`identity.ParseClaims`, `identity.NewClaims`, and `identity.NewTextClaims` accept
raw JSON, decoded Go values, and gateway string maps respectively. Claims are
immutable snapshots with checked accessors. Exact numeric text remains available
where the input preserves it; a decoded float64 is never advertised as an exact
original JSON number, and gateway text is never reparsed as an embedded array.

Processing uses `encoding/json/jsontext` directly. The only production
`encoding/json` import recognizes its `Number` data type, supporting typed SDK
handlers configured with `lambda.WithUseNumber(true)`; no v1 codec is called.
See the [runnable examples](identity/claims_example_test.go).

The default claim allowance is 256 KiB, configurable with
`identity.WithClaimsBudget(bytes)` up to 6 MiB. Each value costs 64 bytes plus
object-name, string and exact-number text bytes. Raw JSON also has a wire-length
limit of the same size. Nesting is limited to 64 containers. These are resource
policies, not heap caps; [measurements](docs/benchmarks.md) include allocation and
retained-memory costs. `edge.WithIdentityClaimsBudget(bytes)` applies the matching
allowance to gateway claims and dedicated scopes.

Handlers receive an owned request body and canceled context after invocation.
Multipart files parsed on the served request are removed on success, error or
panic. Middleware that parses multipart on a separate request copy owns cleanup
of files attached only to that copy. Buffered and pre-handoff streaming panics
propagate after cleanup; post-handoff streaming panics become terminal errors.

- [Implementation plan](docs/plan.md)
- [Design decisions](docs/decisions/README.md)
- [Repository working agreement](AGENTS.md)
- [Release workflow and GitHub App setup](docs/releases.md)
- [Initial codec benchmarks](docs/benchmarks.md)
83 changes: 83 additions & 0 deletions action.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package edge

import (
"errors"
"net/http"
"strings"
)

var (
// ErrActionMissing means the configured header has no represented values.
// An absent header and a nil or empty value slice are all missing.
ErrActionMissing = errors.New("edge: missing action")

// ErrActionAmbiguous means the header has multiple represented values or a
// value containing a comma. Gateway payload 2.0 combines duplicates with
// commas; a literal comma cannot be distinguished from combined values.
ErrActionAmbiguous = errors.New("edge: ambiguous action")

// ErrActionInvalid means the single action value is empty after trimming
// outer spaces and tabs, or does not satisfy the HTTP token grammar.
ErrActionInvalid = errors.New("edge: invalid action")
)

// ActionHeader selects an action from a configured request header.
// Construct it with [NewActionHeader]; its zero value is unconfigured.
// An ActionHeader is immutable, safe to copy, and safe for concurrent use.
type ActionHeader struct {
name string
}

// NewActionHeader validates name as an HTTP field name and returns a selector.
// The name is required and matched case-insensitively; no default is inferred.
// On error, the returned selector is the zero value. Configuration errors are
// separate from [ErrActionMissing], [ErrActionAmbiguous] and [ErrActionInvalid].
func NewActionHeader(name string) (ActionHeader, error) {
if !validToken(name) {
return ActionHeader{}, errors.New("edge: invalid action header name")
}
return ActionHeader{name: http.CanonicalHeaderKey(name)}, nil
}

// Parse returns the single action in headers, trimming only outer SP/HTAB and
// preserving case. An action must be a nonempty HTTP token (RFC 9110 §5.6.2).
// Parse inspects all case-insensitive aliases, including noncanonical map keys.
// Multiple values are ambiguous even if identical; a comma is also ambiguous.
// Multiplicity and comma checks take precedence over token validation.
// Duplicates already lost by a gateway or earlier proxy cannot be detected.
//
// On failure Parse returns an empty string and [ErrActionMissing],
// [ErrActionAmbiguous] or [ErrActionInvalid], matchable with [errors.Is]. An
// unconfigured selector instead returns a configuration error. Errors do not
// include supplied header values. Parse does not modify headers or write an
// HTTP response. Callers must not mutate headers concurrently with Parse.
// Select once and use that same action for authorization and execution.
func (h ActionHeader) Parse(headers http.Header) (string, error) {
if h.name == "" {
return "", errors.New("edge: unconfigured action header")
}
var value string
found := false
for name, values := range headers {
// CanonicalHeaderKey folds only valid ASCII field names. In particular,
// Unicode case equivalents must not alias the configured HTTP name.
if http.CanonicalHeaderKey(name) != h.name || len(values) == 0 {
continue
}
if found || len(values) > 1 {
return "", ErrActionAmbiguous
}
value, found = values[0], true
}
if !found {
return "", ErrActionMissing
}
if strings.Contains(value, ",") {
return "", ErrActionAmbiguous
}
value = strings.Trim(value, " \t")
if !validToken(value) {
return "", ErrActionInvalid
}
return value, nil
}
Loading
Loading