# Ephemera — HTTP service on AWS (Lambda + aws CLI)

> Self-executing Markdown. The **AWS binding** of the *http-service* intent — sibling of
> [`service.cloudflare.md`](./service.cloudflare.md) (Worker), same shared acceptance contract. The cloud is
> the source of truth; this file is intent + write-back ledger + audit trail.
>
> **Provides** `http-service(${FN} @ URL)` — a stable HTTPS endpoint answering JSON. **Requires** zero-or-more
> upstream resources it *binds*: an S3 bucket from [`storage.aws.md`](./storage.aws.md) (`BIND_S3`), a user pool
> from [`auth.aws.md`](./auth.aws.md) (`AUTH=jwt` — token-gated routes), a shared dependency layer from
> [`lambda-layer.aws.md`](./lambda-layer.aws.md) (`BIND_LAYER`), each **discovered** from the cloud, never
> assumed. On AWS a "binding" is an env var + an IAM statement on the execution role (a layer is an attached
> ARN) — the plan wires both.

## 🤖 Director prompt

Observe before acting; verify each step before advancing; stop at 🔴/💥 for human go; write realized values
back into Live State. **Retry through IAM eventual consistency** (a fresh role isn't instantly assumable —
the `create-function` loop below is the proven idiom from [`task-runner.aws.md`](./task-runner.aws.md)).
Every resource name is a pure function of the knobs, so re-runs discover-and-reuse.

> **Status: DOGFOODED LIVE 2026-07-01 (http-api + jwt + ssm + bind-s3, composed, ~$0).** Ran end-to-end
> against throwaway stacks (auth.aws.md pool/client + a flat storage.aws.md bucket as the Requires upstreams;
> everything created, verified, torn down): the **4-clause contract PASSED** — `/healthz` 200; `/ping-store`
> returned live data through the exec-role binding; `/secret-check` read the SSM SecureString in-handler while
> the value was verifiably **absent** from the function config; and the **JWT gate held** (401 with no token —
> asserted *before* the positives — then 200 with a Cognito ID token). Quick-create behaved exactly as authored
> (`--target` ⇒ `$default` route + auto-deploying stage; `update-route` to JWT needed no manual deployment).
> All four created resources tagged + ownership-checked at teardown; absence-verified clean. Dogfood findings:
> 1. **§3's IAM-propagation retry fired live** ("role not yet assumable — retry 1/5" → success) — the loop is
>    load-bearing, not ceremony.
> 2. **Route-auth changes converge asynchronously**: immediately after `update-route` the endpoint still served
>    200 for a few seconds (stale auto-deploy) — §6's jwt-neg now retries briefly instead of single-shotting;
>    a 200 right after gating means *wait*, not *broken*.
> 3. The JWT authorizer accepted the Cognito **ID token** (`aud` = client id) exactly as configured.
> 4. Under a credential broker, all IAM calls here ride `--no-session` (see auth.aws.md's proven note).
> Still unrun: `EXPOSE=function-url` (JWT forced http-api this run), `AUTH=none`, and Function-URL teardown.
> ⚠ Post-dogfood delta (2026-07-03): the handler gained a `/layer-check` route and §3 gained `--layers`
> (`BIND_LAYER` knob) — an un-dogfooded code+config delta that rides the next run.

```
Legend  🟢 create · 🟡 config · 🔴 GATE (human go) · 💥 destructive (human go) · ⏳ wait · ✔ verify
```

## What you need, and why  *(read if Lambda is new to you)*

- **`aws` CLI v2, authenticated** (`aws sts get-caller-identity` succeeds) + **`zip`**, **`curl`**, **`python3`**.
- **A region** (`AWS_REGION`) — Lambda + API Gateway are regional.
- **IAM steps under a credential broker** (aws-vault / SSO): §2's role create and teardown's role delete need
  `--no-session` (EPHEMERA.md gotcha); everything else runs on session creds.
- **(`BIND_S3` only)** an applied [`storage.aws.md`](./storage.aws.md) — the bucket is discovered, not created.
- **(`AUTH=jwt` only)** an applied [`auth.aws.md`](./auth.aws.md) — the authorizer validates that pool's ID tokens
  (`aud` = the app client id), and the acceptance test needs a token minted via that plan's §5.

## Intent

Stand up a **single HTTPS JSON service** — an API, a webhook receiver, a BFF — as one Lambda function behind a
stable URL, with resources *bound* (env var + scoped IAM) instead of hardcoded. Identical *intent* to the Worker
sibling; the AWS asymmetries are **exposure is a separate resource** (a Function URL or an HTTP API — the Worker
gets a URL for free), **bindings are IAM** (the Worker binds by name, config-only), and **JWT auth is managed**
(the HTTP API validates Cognito tokens *before* your code runs — the Worker checks tokens in-handler).

**Shared acceptance contract** (defined in [`service.cloudflare.md`](./service.cloudflare.md); every
http-service binding must pass it):
1. `GET /healthz` → `200` + `{ "ok": true }`
2. a request that touches a **bound** resource returns live data (`GET /ping-store` when `BIND_S3` is set)
3. **routing:** the stable URL serves over HTTPS — here that's the Function URL / HTTP API endpoint itself
   (custom domains are a named omission — see *Deliberately not included*)
4. a **secret** is readable in-handler **and absent from the function's config** (negative:
   `get-function-configuration` env vars must not contain the secret value)
5. *(AWS-specific extension — the Worker sibling has no layers)* when `BIND_LAYER` is set, the function
   imports a module its own zip does **not** contain (the attached layer supplies it); when unset, no layer
   is attached (negative)

## Provisioning Inputs

| # | Question | Options (closed enum) | Default | Sets | Gates |
|---|----------|-----------------------|---------|------|-------|
| 1 | How is the service exposed? | `function-url` / `http-api` | `function-url` | `EXPOSE` | §4 (🔴 public endpoint) + acceptance |
| 2 | Does it need a secret? | `none` / `ssm` | `none` | `SECRETS` | §1 (SSM SecureString) + §2 grant + acceptance 4 |
| 3 | Token-gate the routes? | `none` / `jwt` | `none` | `AUTH` | §5 (Cognito JWT authorizer — needs `EXPOSE=http-api`) |
| 4 | Bind an S3 bucket? | bucket name or empty | empty | `BIND_S3` | discovery + §2 grant + §3 env + acceptance 2 |
| 5 | Attach a shared layer? | lambda-layer.aws.md's `LAYER_NAME` or empty | empty | `BIND_LAYER` | discovery + §3 `--layers` + `/layer-check` + acceptance 5 |
| 6 | Service noun | text — `[a-z0-9-]` | `ephemera-svc` | `SVC` | every resource name (`${SVC}-${ENV}-…`) |
| 7 | Environment | `dev` / `stg` / `uat` / `prod` | `dev` | `ENV` | names + `Environment` tag |
| — | *(ssm)* the secret value | text (secret) | — | `SECRET_VALUE` | §1 (keep out of logs/VCS) |
| — | *(jwt)* upstream auth base name | text — auth.aws.md's `AUTH_NAME` | `ephemera-auth` | `AUTH_NAME` | discovery (which pool the authorizer trusts) |
| — | *(bind_layer)* module to prove | text — an importable name the layer supplies | `requests` | `LAYER_TEST_MODULE` | `/layer-check` (acceptance 5) |

```yaml
# → written into Live State once resolved (the deterministic input to every step below)
resolved_inputs:
  expose:   function-url   # function-url | http-api
  secrets:  none           # none | ssm
  auth:     none           # none | jwt (needs expose=http-api)
  bind_s3:  ""             # bucket name or empty
  bind_layer: ""           # lambda-layer.aws.md's LAYER_NAME, or empty
  layer_test_module: requests  # iff bind_layer — an importable name the layer supplies (proves the attach)
  auth_name: ephemera-auth # iff jwt — the upstream auth.aws.md base name
  svc:      ephemera-svc
  env:      dev
  resolved_by: <human who confirmed>
  resolved_at: <timestamp>
```

> **Determinism.** Function, role, parameter, and API names are pure functions of `SVC`+`ENV`; every 🟢
> observes first (get-function / get-role / get-parameter / get-apis by name) and reuses on a hit. The handler
> zip is generated from the heredoc below, so the code artifact is also a pure function of the plan.
> `AUTH=jwt` requires `EXPOSE=http-api` (Function URLs have no managed JWT authorizer) — §0 enforces the
> combination instead of silently ignoring it. ⚠ API names are NOT unique and `get-apis` is single-page —
> §4b/teardown refuse (loudly) if the listing overflows rather than trust a possibly-partial page.

## Live State

```yaml
status:        not-created      # published template - run it to realize state
last_action:   teardown — api/function/param/role deleted (each ownership-checked) after the contract passed;
               upstream auth pool/client + flat bucket also cycled down (💥 go: Mark)
last_verified: 2026-07-01 composed dogfood (throwaway, us-west-2, ~$0) — 4-clause contract PASSED; JWT gate
               proven (401→200); exec-role bindings live; see the verify table.

resolved_inputs:            # as run 2026-07-01 (🔴 http-api exposure + 💥 teardown: go recorded, Mark)
  expose:   http-api
  secrets:  ssm
  auth:     jwt
  bind_s3:  assets-dev-<ACCOUNT_ID>   # flat upstream from storage.aws.md
  auth_name: ephemera-auth
  svc:      ephemera-svc
  env:      dev
realized:                    # cleared by teardown
  AWS_REGION:   —
  ROLE_ARN:     —            # §2
  FN_ARN:       —            # §3
  BASE_URL:     —            # §4 (Function URL, or the HTTP API endpoint)
  API_ID:       —            # §4b (iff expose=http-api)
  AUTHORIZER_ID: —           # §5 (iff auth=jwt)
  USER_POOL_ID: —            # discovered (iff auth=jwt; borrowed)
  CLIENT_ID:    —            # discovered (iff auth=jwt; borrowed)
  SECRET_PARAM: —            # §1 (iff secrets=ssm; the NAME, never the value)
  LAYER_ARN:    —            # discovered (iff bind_layer; borrowed — never torn down here)
```

| ✔ check                             | expected                                          | observed (2026-07-01 dogfood) | result |
|-------------------------------------|---------------------------------------------------|----------|--------|
| healthz                             | `GET /healthz` → 200 `{"ok": true}`               | 200 + body (with token) | PASS |
| bound store answers (bind_s3)       | `GET /ping-store` → 200 live data                 | `store: ok` via exec-role grant | PASS |
| secret read in-handler (ssm)        | `GET /secret-check` → `secret_read: true`         | read via SSM at runtime | PASS |
| secret absent from config (neg)     | function env JSON does NOT contain the value      | env carries names only | PASS |
| unauthenticated rejected (jwt, neg) | no token → `401`; minted ID token → `200`         | 401 (after deploy converged) → 200 | PASS |
| layer import (bind_layer)           | `/layer-check` → `layer_import: true`; ARN in config | — (knob unrun in the 2026-07-01 dogfood) | — |
| tags present                        | function + role (+ api / param) carry `ManagedBy` | all four: ephemera | PASS |
| teardown leaves nothing             | fn/role/param/api absent after 💥                 | all absence-verified | PASS |

## TAGS — provenance & cost tags

Tag **on create**: Lambda + HTTP API take a **JSON map** (`tags_map`); the IAM role + SSM parameter take
**Key/Value lists** (`tags_kv`). ⚠ SSM `put-parameter` refuses `--tags` together with `--overwrite` — §1 tags
on first create only (re-runs skip). Renderers inline in §0 (canonical: `scripts/tags.sh`).

## 0. Variables

```bash
set -euo pipefail
export AWS_REGION="${AWS_REGION:-us-west-2}"
export ENV="${ENV:-dev}"
export SVC="${SVC:-ephemera-svc}"
export EXPOSE="${EXPOSE:-function-url}"      # function-url | http-api
export SECRETS="${SECRETS:-none}"            # none | ssm
export AUTH="${AUTH:-none}"                  # none | jwt (needs EXPOSE=http-api)
export BIND_S3="${BIND_S3:-}"                # bucket name from storage.aws.md, or empty
export BIND_LAYER="${BIND_LAYER:-}"          # lambda-layer.aws.md's LAYER_NAME to attach, or empty
export LAYER_TEST_MODULE="${LAYER_TEST_MODULE:-requests}"  # iff BIND_LAYER: importable name the layer supplies
export SECRET_VALUE="${SECRET_VALUE:-}"      # required iff SECRETS=ssm (secret — keep out of logs/VCS)
export AUTH_NAME="${AUTH_NAME:-ephemera-auth}"  # iff AUTH=jwt: auth.aws.md's base name (pool/client discovery)

printf '%s' "$SVC" | grep -Eq '^[a-z0-9-]+$' || { echo "SVC must be [a-z0-9-]"; exit 1; }
if [ "$AUTH" = "jwt" ] && [ "$EXPOSE" != "http-api" ]; then
  echo "AUTH=jwt needs EXPOSE=http-api (Function URLs have no managed JWT authorizer)"; exit 1
fi

FN="${SVC}-${ENV}"
ROLE="${SVC}-${ENV}-exec"
API_NAME="${SVC}-${ENV}-api"
SECRET_PARAM="/${SVC}/${ENV}/app-secret"
RUNTIME="python3.12"                          # boto3 ships in the runtime — no bundling for the SSM/S3 reads
BUILD="/tmp/${FN}-build"
ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"

# single-page-listing guard used by §4b + teardown (API names are not unique; a partial page must not be trusted)
apis_json() {
  local J; J="$(aws apigatewayv2 get-apis --region "$AWS_REGION" --max-results 100 --output json)"
  printf '%s' "$J" | grep -q '"NextToken"' \
    && { echo ">100 APIs in region — page the listing before trusting name-discovery" >&2; return 1; }
  printf '%s' "$J"
}

# ── TAGS — resolved once (canonical: scripts/tags.sh) ──
PLAN_SOURCE="service.aws.md"
PLAN_VERSION="2026-07-05"
TAG_COST_CENTER="${TAG_COST_CENTER:-}"; TAG_OWNER="${TAG_OWNER:-}"
TAGS="$(printf '%s\n' "ManagedBy=ephemera" "Source=${PLAN_SOURCE}" "PlanVersion=${PLAN_VERSION}" \
  "CostCenter=${TAG_COST_CENTER}" "Owner=${TAG_OWNER}" "Environment=${ENV}")"
_tags_list() { printf '%s\n' "$TAGS" "$@" | awk '
  { eq=index($0,"="); if(eq==0) next; k=substr($0,1,eq-1); v=substr($0,eq+1); if(v=="") next;
    val[k]=v; if(!(k in seen)){ order[++n]=k; seen[k]=1 } }
  END { for(i=1;i<=n;i++) print order[i]"="val[order[i]] }'; }
tags_kv()  { _tags_list "$@" | while IFS='=' read -r k v; do printf 'Key=%s,Value=%s ' "$k" "$v"; done; }
tags_map() { printf '{%s}' "$(_tags_list "$@" | while IFS='=' read -r k v; do printf '"%s":"%s",' "$k" "$v"; done | sed 's/,$//')"; }
```

## Requires-discovery (read-only — pre-fill from the cloud)  ✔

```bash
# BIND_S3 ⇒ the bucket must exist (storage.aws.md's realized name)
if [ -n "$BIND_S3" ]; then
  aws s3api head-bucket --bucket "$BIND_S3" 2>/dev/null \
    || { echo "bucket ${BIND_S3} not found — apply storage.aws.md first"; exit 1; }
fi
# AUTH=jwt ⇒ discover auth.aws.md's pool + client by their deterministic names
if [ "$AUTH" = "jwt" ]; then
  USER_POOL_ID="$(aws cognito-idp list-user-pools --region "$AWS_REGION" --max-results 60 \
    --query "UserPools[?Name=='${AUTH_NAME}-${ENV}'].Id | [0]" --output text)"
  { [ "$USER_POOL_ID" = "None" ] || [ -z "$USER_POOL_ID" ]; } \
    && { echo "user pool ${AUTH_NAME}-${ENV} not found — apply auth.aws.md first"; exit 1; }
  CLIENT_ID="$(aws cognito-idp list-user-pool-clients --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" \
    --max-results 60 --query "UserPoolClients[?ClientName=='${AUTH_NAME}-${ENV}-client'].ClientId | [0]" --output text)"
  { [ "$CLIENT_ID" = "None" ] || [ -z "$CLIENT_ID" ]; } && { echo "app client not found"; exit 1; }
fi
# BIND_LAYER ⇒ discover the LATEST published version of the layer by name (lambda-layer.aws.md's output)
LAYER_ARN=""
if [ -n "$BIND_LAYER" ]; then
  # max_by, not LayerVersions[0] — the API documents no ordering (and [0] garbles under auto-pagination)
  LAYER_ARN="$(aws lambda list-layer-versions --region "$AWS_REGION" --layer-name "$BIND_LAYER" \
    --query 'max_by(LayerVersions, &Version).LayerVersionArn' --output text 2>/dev/null || true)"
  { [ -z "$LAYER_ARN" ] || [ "$LAYER_ARN" = "None" ]; } \
    && { echo "layer ${BIND_LAYER} not found — apply lambda-layer.aws.md first"; exit 1; }
fi
```
> → Live State: `BIND_S3` / `USER_POOL_ID` + `CLIENT_ID` / `LAYER_ARN` (discovered, NOT created).

## Dependency frontier

```
(§1 SSM secret 🟢 iff ssm) ──┐
(BIND_S3 ── discovered) ─────┼─> §2 exec role 🟢 (grants reference param/bucket ARNs) ─> §3 function 🟢 ⏳(IAM retry)
                             │        └──────────────────────────────────────────────────────────┐
(AUTH=jwt: pool ── discovered) ─> §4 exposure 🔴🟢 (URL or API + invoke permission) ─> §5 authorizer 🟡 ─> §6 ✔
```

Non-negotiable edges: the **role's inline grants need the param/bucket identities first** (ARN strings are
deterministic, but discovery must pass); **`create-function` needs the role** (and retries IAM propagation);
**`add-permission` needs the API id** (chicken-and-egg — permission after create-api); the **authorizer needs
the discovered pool**. Teardown reverses.

## 1. Secret — iff `SECRETS=ssm`  🟢  *(SecureString; the name is config, the value never is)*

```bash
if [ "$SECRETS" = "ssm" ]; then
  : "${SECRET_VALUE:?SECRETS=ssm needs SECRET_VALUE}"
  # observe — create-with-tags on a miss; overwrite is a human edit, not silent drift (⚠ --tags + --overwrite don't mix)
  if aws ssm get-parameter --region "$AWS_REGION" --name "$SECRET_PARAM" >/dev/null 2>&1; then
    echo "param ${SECRET_PARAM} exists — reuse (rotate = explicit put-parameter --overwrite, no --tags)"
  else
    aws ssm put-parameter --region "$AWS_REGION" --name "$SECRET_PARAM" --type SecureString \
      --value "$SECRET_VALUE" --tags $(tags_kv)
  fi
fi
```
```bash
# ✔ present + encrypted (skip when none) — reads metadata, never the value
if [ "$SECRETS" = "ssm" ]; then
  aws ssm get-parameter --region "$AWS_REGION" --name "$SECRET_PARAM" \
    --query 'Parameter.{name:Name,type:Type}' --output text | grep -q SecureString \
    && echo "secret param ok (SecureString)" || { echo "param wrong type/missing"; exit 1; }
fi
```
> → Live State: `SECRET_PARAM` (the name only).

## 2. Execution role  🟢  *(IAM — `--no-session` under a credential broker)*

```bash
mkdir -p "$BUILD"
cat > "$BUILD/trust.json" <<'JSON'
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}
JSON
ROLE_ARN="$(aws iam get-role --role-name "$ROLE" --query 'Role.Arn' --output text 2>/dev/null)" || \
ROLE_ARN="$(aws iam create-role --role-name "$ROLE" \
  --assume-role-policy-document file://"$BUILD/trust.json" --tags $(tags_kv) \
  --query 'Role.Arn' --output text)"
aws iam attach-role-policy --role-name "$ROLE" \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole   # logs (idempotent)

# least-priv inline grants — ONLY what the knobs bind (pure function of the inputs; upsert by name)
GRANTS='[]'
[ "$SECRETS" = "ssm" ] && GRANTS="$(printf '%s' "$GRANTS" | python3 -c "import sys,json;g=json.load(sys.stdin);g.append({'Sid':'ReadSecret','Effect':'Allow','Action':'ssm:GetParameter','Resource':'arn:aws:ssm:${AWS_REGION}:${ACCOUNT_ID}:parameter${SECRET_PARAM}'});print(json.dumps(g))")"
[ -n "$BIND_S3" ] && GRANTS="$(printf '%s' "$GRANTS" | python3 -c "import sys,json;g=json.load(sys.stdin);g.append({'Sid':'PingStore','Effect':'Allow','Action':'s3:ListBucket','Resource':'arn:aws:s3:::${BIND_S3}'});print(json.dumps(g))")"
if [ "$GRANTS" != "[]" ]; then
  aws iam put-role-policy --role-name "$ROLE" --policy-name "${FN}-bindings" \
    --policy-document "{\"Version\":\"2012-10-17\",\"Statement\":${GRANTS}}"
fi
```
```bash
# ✔ role exists; bindings policy present iff any binding is on
aws iam get-role --role-name "$ROLE" --query 'Role.RoleName' --output text
if [ "$SECRETS" = "ssm" ] || [ -n "$BIND_S3" ]; then
  aws iam get-role-policy --role-name "$ROLE" --policy-name "${FN}-bindings" --query PolicyName --output text
fi
```
> → Live State: `ROLE_ARN`.  ⏳ IAM is eventually consistent — §3 retries `create-function`.

## 3. Function  🟢  *(handler generated here — the artifact is part of the plan)*

```bash
cat > "$BUILD/lambda_function.py" <<'PY'
import json, os

def _r(code, body):
    return {"statusCode": code, "headers": {"content-type": "application/json"}, "body": json.dumps(body)}

def handler(event, context):
    path = event.get("rawPath") or event.get("path") or "/"
    if path == "/healthz":
        return _r(200, {"ok": True})
    if path == "/ping-store":
        b = os.environ.get("BIND_S3_BUCKET")
        if not b:
            return _r(404, {"error": "no store bound"})
        import boto3
        boto3.client("s3").list_objects_v2(Bucket=b, MaxKeys=1)   # live proof, content ignored
        return _r(200, {"store": "ok", "bucket": b})
    if path == "/secret-check":
        p = os.environ.get("SECRET_PARAM")
        if not p:
            return _r(404, {"error": "no secret configured"})
        import boto3
        v = boto3.client("ssm").get_parameter(Name=p, WithDecryption=True)["Parameter"]["Value"]
        return _r(200, {"secret_read": True, "length": len(v)})   # NEVER the value
    if path == "/layer-check":
        m = os.environ.get("LAYER_TEST_MODULE")
        if not m:
            return _r(404, {"error": "no layer module configured"})
        try:
            import importlib
            importlib.import_module(m)                              # supplied by the attached layer, NOT this zip
            return _r(200, {"layer_import": True, "module": m})
        except Exception as e:
            return _r(200, {"layer_import": False, "module": m, "error": str(e)})
    return _r(404, {"error": "not found"})
PY
( cd "$BUILD" && zip -q -X fn.zip lambda_function.py )

# env carries NAMES only — the secret value lives in SSM, read at runtime (contract clause 4's negative)
ENVJSON="$(B="$BIND_S3" P="$([ "$SECRETS" = ssm ] && printf '%s' "$SECRET_PARAM")" LM="$([ -n "$BIND_LAYER" ] && printf '%s' "$LAYER_TEST_MODULE")" python3 -c "import json,os;d={k:v for k,v in {'BIND_S3_BUCKET':os.environ.get('B',''),'SECRET_PARAM':os.environ.get('P',''),'LAYER_TEST_MODULE':os.environ.get('LM','')}.items() if v};print(json.dumps({'Variables':d}))")"
# layers (word-split under bash): create-path attaches only when set (a new fn has no layers to shed);
# the UPDATE path must ALWAYS pass --layers — bare --layers = empty list ⇒ detach-all — so turning the knob
# OFF reconciles. Omitting the flag would let a previously attached layer silently persist (resolved_inputs
# and the cloud diverge with no drift signal). Bare-flag = empty-list CONFIRMED LIVE 2026-07-05
# (lambda-layer.aws.md dogfood: detach → Layers[] empty → import failed again — full round-trip).
LAYERS_ARG=""; [ -n "$LAYER_ARN" ] && LAYERS_ARG="--layers $LAYER_ARN"
LAYERS_RECONCILE="--layers${LAYER_ARN:+ $LAYER_ARN}"

if aws lambda get-function --region "$AWS_REGION" --function-name "$FN" >/dev/null 2>&1; then
  # reuse → converge code + config (deterministic artifact ⇒ update is idempotent)
  aws lambda update-function-code --region "$AWS_REGION" --function-name "$FN" \
    --zip-file fileb://"$BUILD/fn.zip" >/dev/null
  aws lambda wait function-updated --region "$AWS_REGION" --function-name "$FN"
  aws lambda update-function-configuration --region "$AWS_REGION" --function-name "$FN" \
    --environment "$ENVJSON" $LAYERS_RECONCILE >/dev/null
else
  for i in 1 2 3 4 5; do   # ⏳ fresh-role propagation (proven idiom)
    if aws lambda create-function --region "$AWS_REGION" --function-name "$FN" \
         --runtime "$RUNTIME" --role "$ROLE_ARN" --handler lambda_function.handler \
         --zip-file fileb://"$BUILD/fn.zip" --timeout 10 --memory-size 128 \
         --environment "$ENVJSON" $LAYERS_ARG --tags "$(tags_map)" >/dev/null; then break; fi
    if [ "$i" = 5 ]; then echo "create-function kept failing"; exit 1; fi
    echo "role not yet assumable — retry ${i}/5"; sleep 5
  done
  aws lambda wait function-active --region "$AWS_REGION" --function-name "$FN"   # don't hand §4 a Pending function
fi
FN_ARN="$(aws lambda get-function --region "$AWS_REGION" --function-name "$FN" \
  --query 'Configuration.FunctionArn' --output text)"
```
```bash
# ✔ function live, runtime + env NAMES as expected
aws lambda get-function-configuration --region "$AWS_REGION" --function-name "$FN" \
  --query '{runtime:Runtime,state:State,env:Environment.Variables}' --output json
```
> → Live State: `FN_ARN`.

## 4. Exposure  🔴🟢  *(the step that puts an endpoint on the public internet)*

> 🔴 Human go — a public HTTPS endpoint is a **denial-of-wallet surface** (each hit bills a Lambda invoke;
> `http-api` adds ~$1/M requests). `AUTH=jwt` narrows it after §5, but the URL itself is world-reachable.
> Both branches: observe first, create on a miss.

```bash
if [ "$EXPOSE" = "function-url" ]; then
  # 4a 🔴🟢 Function URL — zero extra infra, no API Gateway charge
  if ! aws lambda get-function-url-config --region "$AWS_REGION" --function-name "$FN" >/dev/null 2>&1; then
    aws lambda create-function-url-config --region "$AWS_REGION" --function-name "$FN" --auth-type NONE >/dev/null
  fi
  LP="$(aws lambda get-policy --region "$AWS_REGION" --function-name "$FN" --query Policy --output text 2>&1 || true)"
  printf '%s' "$LP" | grep -q FunctionURLAllowPublicAccess || \
    aws lambda add-permission --region "$AWS_REGION" --function-name "$FN" \
      --statement-id FunctionURLAllowPublicAccess --action lambda:InvokeFunctionUrl \
      --principal '*' --function-url-auth-type NONE >/dev/null
  BASE_URL="$(aws lambda get-function-url-config --region "$AWS_REGION" --function-name "$FN" \
    --query FunctionUrl --output text)"; BASE_URL="${BASE_URL%/}"
else
  # 4b 🔴🟢 HTTP API (quick-create: default route + stage + Lambda integration in one call)
  API_ID="$(apis_json | APIN="$API_NAME" python3 -c 'import sys,json,os;m=[a["ApiId"] for a in json.load(sys.stdin).get("Items",[]) if a.get("Name")==os.environ["APIN"]];print(m[0] if m else "")')"
  if [ -z "$API_ID" ]; then
    API_ID="$(aws apigatewayv2 create-api --region "$AWS_REGION" --name "$API_NAME" \
      --protocol-type HTTP --target "$FN_ARN" --tags "$(tags_map)" --query ApiId --output text)"
  fi
  LP="$(aws lambda get-policy --region "$AWS_REGION" --function-name "$FN" --query Policy --output text 2>&1 || true)"
  printf '%s' "$LP" | grep -q "apigw-${API_ID}" || \
    aws lambda add-permission --region "$AWS_REGION" --function-name "$FN" \
      --statement-id "apigw-${API_ID}" --action lambda:InvokeFunction \
      --principal apigateway.amazonaws.com \
      --source-arn "arn:aws:execute-api:${AWS_REGION}:${ACCOUNT_ID}:${API_ID}/*/*" >/dev/null
  BASE_URL="$(aws apigatewayv2 get-api --region "$AWS_REGION" --api-id "$API_ID" \
    --query ApiEndpoint --output text)"
fi
echo "BASE_URL=${BASE_URL}"
```
```bash
# ✔ the endpoint answers (retry: first hit may cold-start)
CODE=000
for i in 1 2 3; do
  CODE="$(curl -s -o /dev/null -w '%{http_code}' "${BASE_URL}/healthz")"
  if [ "$CODE" = "200" ] || [ "$CODE" = "401" ]; then break; fi   # 401 is §5's doing — still "answering"
  sleep 3
done
{ [ "$CODE" = "200" ] || [ "$CODE" = "401" ]; } && echo "endpoint answers (${CODE})" || { echo "endpoint dead (${CODE})"; exit 1; }
```
> → Live State: `BASE_URL` (+ `API_ID`); `status: live` once §6 passes.

## 5. JWT authorizer — iff `AUTH=jwt`  🟡  *(managed token check, before your code runs)*

> The HTTP API validates a Cognito **ID token** (`aud` = the app client id) against the pool's issuer — requests
> without a valid token never reach the function. This is the recovered Amplify API-auth shape, as composition:
> the pool comes from [`auth.aws.md`](./auth.aws.md), discovered above.

```bash
if [ "$AUTH" = "jwt" ]; then
  AUTHORIZER_ID="$(aws apigatewayv2 get-authorizers --region "$AWS_REGION" --api-id "$API_ID" \
    --query "Items[?Name=='${FN}-jwt'].AuthorizerId | [0]" --output text)"
  if [ "$AUTHORIZER_ID" = "None" ] || [ -z "$AUTHORIZER_ID" ]; then
    AUTHORIZER_ID="$(aws apigatewayv2 create-authorizer --region "$AWS_REGION" --api-id "$API_ID" \
      --name "${FN}-jwt" --authorizer-type JWT --identity-source '$request.header.Authorization' \
      --jwt-configuration "{\"Audience\":[\"${CLIENT_ID}\"],\"Issuer\":\"https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}\"}" \
      --query AuthorizerId --output text)"
  fi
  ROUTE_ID="$(aws apigatewayv2 get-routes --region "$AWS_REGION" --api-id "$API_ID" \
    --query 'Items[?RouteKey==`$default`].RouteId | [0]' --output text)"
  aws apigatewayv2 update-route --region "$AWS_REGION" --api-id "$API_ID" --route-id "$ROUTE_ID" \
    --authorization-type JWT --authorizer-id "$AUTHORIZER_ID" >/dev/null
fi
```
```bash
# ✔ the default route is JWT-gated (skip when none)
if [ "$AUTH" = "jwt" ]; then
  aws apigatewayv2 get-routes --region "$AWS_REGION" --api-id "$API_ID" \
    --query 'Items[?RouteKey==`$default`].{auth:AuthorizationType}' --output text | grep -q JWT \
    && echo "route: JWT-gated" || { echo "route not gated"; exit 1; }
fi
```
> → Live State: `AUTHORIZER_ID`.

## 6. Acceptance verify  ✔  *(the shared contract, pure curl)*

```bash
TOKEN_HDR=""
if [ "$AUTH" = "jwt" ]; then
  # a valid ID token is minted via auth.aws.md §5 (sign-up → initiate-auth); export it as ID_TOKEN first
  : "${ID_TOKEN:?AUTH=jwt acceptance needs ID_TOKEN — mint one via auth.aws.md §5 and export it}"
  # negative FIRST: no token → 401. Retry briefly — §5's update-route rides the stage's auto-deploy and
  # converges in seconds (proven live: a 200 right after gating means WAIT, not broken).
  CODE=000
  for i in 1 2 3 4 5; do
    CODE="$(curl -s -o /dev/null -w '%{http_code}' "${BASE_URL}/healthz")"
    if [ "$CODE" = "401" ]; then break; fi
    sleep 4
  done
  [ "$CODE" = "401" ] && echo "jwt-neg: unauthenticated → 401" || { echo "jwt-neg: expected 401, got ${CODE} — gate not enforced"; exit 1; }
  TOKEN_HDR="Authorization: Bearer ${ID_TOKEN}"
fi

# 1 — healthz (with the token when jwt)
curl -s ${TOKEN_HDR:+-H "$TOKEN_HDR"} "${BASE_URL}/healthz" | grep -q '"ok": *true' \
  && echo "1: healthz OK" || { echo "1: healthz FAILED"; exit 1; }

# 2 — the bound store answers with live data (skip when no binding)
if [ -n "$BIND_S3" ]; then
  curl -s ${TOKEN_HDR:+-H "$TOKEN_HDR"} "${BASE_URL}/ping-store" | grep -q '"store": *"ok"' \
    && echo "2: bound store OK" || { echo "2: ping-store FAILED"; exit 1; }
fi

# 4 — secret readable in-handler… (skip when none)
if [ "$SECRETS" = "ssm" ]; then
  curl -s ${TOKEN_HDR:+-H "$TOKEN_HDR"} "${BASE_URL}/secret-check" | grep -q '"secret_read": *true' \
    && echo "4: secret read in-handler" || { echo "4: secret-check FAILED"; exit 1; }
  # …and ABSENT from the function config (the negative that makes it a secret).
  # SECRET_VALUE must be exported for this — an empty grep pattern matches everything (false alarm by design).
  : "${SECRET_VALUE:?4-neg needs SECRET_VALUE exported (the value §1 stored)}"
  ENVJ="$(aws lambda get-function-configuration --region "$AWS_REGION" --function-name "$FN" \
    --query 'Environment.Variables' --output json)"
  printf '%s' "$ENVJ" | grep -qF "$SECRET_VALUE" \
    && { echo "4-neg: SECRET VALUE IS IN CONFIG"; exit 1; } || echo "4-neg: config clean"
fi

# 5 — (AWS-binding-specific; the Worker sibling has no layers) the attached layer supplies an import the
#     function's own zip does NOT contain (skip when no layer)
if [ -n "$BIND_LAYER" ]; then
  curl -s ${TOKEN_HDR:+-H "$TOKEN_HDR"} "${BASE_URL}/layer-check" | grep -q '"layer_import": *true' \
    && echo "5: layer import OK (${LAYER_TEST_MODULE} via ${BIND_LAYER})" || { echo "5: layer-check FAILED"; exit 1; }
  aws lambda get-function-configuration --region "$AWS_REGION" --function-name "$FN" \
    --query 'Layers[].Arn' --output text | grep -q "layer:${BIND_LAYER}:" \
    && echo "5b: layer ARN attached in config" || { echo "5b: layer not attached"; exit 1; }
else
  # 5-neg — knob OFF ⇒ NO layer attached (asserts the §3 detach-reconcile converged; without this, an
  # orphaned layer from a previous apply would pass silently)
  LAYERS_NOW="$(aws lambda get-function-configuration --region "$AWS_REGION" --function-name "$FN" \
    --query 'Layers[].Arn' --output text)"
  { [ -z "$LAYERS_NOW" ] || [ "$LAYERS_NOW" = "None" ]; } && echo "5-neg: no layers attached" \
    || { echo "5-neg: UNEXPECTED LAYERS STILL ATTACHED: ${LAYERS_NOW}"; exit 1; }
fi

# tags (drift) — everything this plan created carries our provenance
FT="$(aws lambda list-tags --region "$AWS_REGION" --resource "$FN_ARN" --output json)"
printf '%s' "$FT" | grep -q '"ManagedBy": *"ephemera"' && echo "function tags ok" || { echo "function untagged"; exit 1; }
RT="$(aws iam list-role-tags --role-name "$ROLE" --query "Tags[?Key=='ManagedBy'].Value | [0]" --output text)"
[ "$RT" = "ephemera" ] && echo "role tags ok" || { echo "role untagged"; exit 1; }
if [ "$EXPOSE" = "http-api" ]; then
  AT="$(aws apigatewayv2 get-tags --region "$AWS_REGION" \
    --resource-arn "arn:aws:apigateway:${AWS_REGION}::/apis/${API_ID}" --output json)"
  printf '%s' "$AT" | grep -q '"ManagedBy": *"ephemera"' && echo "api tags ok" || { echo "api untagged"; exit 1; }
fi
if [ "$SECRETS" = "ssm" ]; then
  PT="$(aws ssm list-tags-for-resource --region "$AWS_REGION" --resource-type Parameter \
    --resource-id "$SECRET_PARAM" --output json)"
  printf '%s' "$PT" | grep -q '"ManagedBy"' && echo "param tags ok" || { echo "param untagged"; exit 1; }
fi
```
> → Live State: fill the verify rows, `last_verified`, `status: live`.

## Update (idempotent reconcile)  🟡

- **Code/config change** → §3's reuse branch is the update path (`update-function-code` → `wait
  function-updated` → `update-function-configuration`); the artifact is deterministic, so re-running converges.
- **Rotate the secret** → `put-parameter --overwrite` (no `--tags` — they don't mix); the handler reads live, no
  redeploy needed. Rotate `SECRET_VALUE` in your env too or acceptance 4-neg can't check.
- **Toggle `AUTH`** → §5 on; off = `update-route --authorization-type NONE` then `delete-authorizer`.
- **Toggle `EXPOSE`** → run the other branch of §4, then tear the old exposure down (they can coexist briefly).
- **Add/remove a binding** → §2's `${FN}-bindings` inline policy is a full upsert; §3's env update converges.
- **Attach/bump/detach a layer** → set (or clear) `BIND_LAYER` and re-run §3: the reuse branch always passes
  a layers argument, so attach, latest-version bump, *and* knob-off detach all reconcile through the same
  `update-function-configuration` (bare `--layers` = detach-all). The version float on re-run is intended —
  discovery picks the upstream's latest via `max_by`.

## Teardown — observe-first, resumable  💥

> 💥 Human go. Reverse order: exposure first (stop the world reaching it), then function, secret, role. Removes
> only what this plan created — the bound bucket and the user pool are **borrowed** and untouched. **Every
> delete is ownership-checked** (`ManagedBy=ephemera`) — name-discovery must never destroy a same-named
> resource this plan didn't make. IAM deletes need `--no-session` under a broker.

```bash
# observe — re-discover by deterministic names
FN_EXISTS=no;  aws lambda get-function --region "$AWS_REGION" --function-name "$FN" >/dev/null 2>&1 && FN_EXISTS=yes
API_ID="$(apis_json | APIN="$API_NAME" python3 -c 'import sys,json,os;m=[a["ApiId"] for a in json.load(sys.stdin).get("Items",[]) if a.get("Name")==os.environ["APIN"]];print(m[0] if m else "")')"

# 💥 4 — exposure (either shape may exist; each ownership-checked or a no-op when absent)
if [ -n "$API_ID" ]; then
  AT="$(aws apigatewayv2 get-tags --region "$AWS_REGION" \
    --resource-arn "arn:aws:apigateway:${AWS_REGION}::/apis/${API_ID}" --output json 2>&1 || true)"
  printf '%s' "$AT" | grep -q '"ManagedBy": *"ephemera"' \
    || { echo "api ${API_ID} lacks ManagedBy=ephemera — not ours to delete"; exit 1; }
  aws apigatewayv2 delete-api --region "$AWS_REGION" --api-id "$API_ID"
fi
if [ "$FN_EXISTS" = yes ]; then
  aws lambda delete-function-url-config --region "$AWS_REGION" --function-name "$FN" 2>/dev/null || true
fi

# 💥 3 — the function (ownership check first)
if [ "$FN_EXISTS" = yes ]; then
  FN_ARN="$(aws lambda get-function --region "$AWS_REGION" --function-name "$FN" --query 'Configuration.FunctionArn' --output text)"
  FT="$(aws lambda list-tags --region "$AWS_REGION" --resource "$FN_ARN" --output json 2>&1 || true)"
  printf '%s' "$FT" | grep -q '"ManagedBy": *"ephemera"' \
    || { echo "function ${FN} lacks ManagedBy=ephemera — not ours to delete"; exit 1; }
  aws lambda delete-function --region "$AWS_REGION" --function-name "$FN"
fi

# 💥 1 — the secret (ownership-checked; skip when already gone)
if aws ssm get-parameter --region "$AWS_REGION" --name "$SECRET_PARAM" >/dev/null 2>&1; then
  PT="$(aws ssm list-tags-for-resource --region "$AWS_REGION" --resource-type Parameter \
    --resource-id "$SECRET_PARAM" --output json 2>&1 || true)"
  printf '%s' "$PT" | grep -q '"ManagedBy"' \
    || { echo "param ${SECRET_PARAM} lacks our tag — not ours to delete"; exit 1; }
  aws ssm delete-parameter --region "$AWS_REGION" --name "$SECRET_PARAM"
fi

# 💥 2 — the role: ownership check, managed policy off, inline off, delete (--no-session under a broker)
if aws iam get-role --role-name "$ROLE" >/dev/null 2>&1; then
  RT="$(aws iam list-role-tags --role-name "$ROLE" --query "Tags[?Key=='ManagedBy'].Value | [0]" --output text 2>&1 || true)"
  [ "$RT" = "ephemera" ] || { echo "role ${ROLE} lacks ManagedBy=ephemera — not ours to delete"; exit 1; }
  aws iam detach-role-policy --role-name "$ROLE" \
    --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 2>/dev/null || true
  aws iam delete-role-policy --role-name "$ROLE" --policy-name "${FN}-bindings" 2>/dev/null || true
  aws iam delete-role --role-name "$ROLE"
fi
```
```bash
# ✔ teardown verify — capture, then assert absence (pipefail-safe)
OUT="$(aws lambda get-function --region "$AWS_REGION" --function-name "$FN" 2>&1 || true)"
printf '%s' "$OUT" | grep -q ResourceNotFound && echo "function gone" || { echo "function STILL EXISTS"; exit 1; }
OUT="$(aws iam get-role --role-name "$ROLE" 2>&1 || true)"
printf '%s' "$OUT" | grep -q NoSuchEntity && echo "role gone" || { echo "role still exists (or IAM unreachable)"; exit 1; }
if [ "$SECRETS" = "ssm" ]; then
  OUT="$(aws ssm get-parameter --region "$AWS_REGION" --name "$SECRET_PARAM" 2>&1 || true)"
  printf '%s' "$OUT" | grep -q ParameterNotFound && echo "param gone" || { echo "param STILL EXISTS"; exit 1; }
fi
if [ "$EXPOSE" = "http-api" ]; then
  API_LEFT="$(apis_json | APIN="$API_NAME" python3 -c 'import sys,json,os;m=[a["ApiId"] for a in json.load(sys.stdin).get("Items",[]) if a.get("Name")==os.environ["APIN"]];print(m[0] if m else "")')"
  [ -z "$API_LEFT" ] && echo "api gone" || { echo "api STILL EXISTS"; exit 1; }
fi
```
> → Live State: `status: gone`, clear realized ids.

## Composition — how this plugs into the fleet

`http-service(${FN} @ URL)` is the AWS front door: point a webhook at it, put it behind a custom domain later
(named omission below), or call it from an app. Upstream seams recovered from the Amplify inventory:
[`storage.aws.md`](./storage.aws.md)'s bucket binds via `BIND_S3` (env + least-priv IAM);
[`auth.aws.md`](./auth.aws.md)'s pool gates it via `AUTH=jwt` (managed token validation — the pool's ID tokens,
`aud` = client id); [`lambda-layer.aws.md`](./lambda-layer.aws.md)'s shared layer attaches via `BIND_LAYER` (a
version ARN discovered by name, put on the function's `--layers`; `/layer-check` proves an import the zip lacks);
[`task-runner.aws.md`](./task-runner.aws.md) remains the shape for *queue-driven* work —
this plan is the *request-driven* twin. A managed GraphQL API is a **different intent**, out of this plan's
scope. Sibling binding: [`service.cloudflare.md`](./service.cloudflare.md) — same contract; the Worker gets its
URL for free and binds by name, this binding pays an exposure step and binds by IAM.

## Deliberately not included

- **Custom domain** (API Gateway domain name + ACM cert + Route 53 alias) — a real production need, but a
  three-resource sub-plan with a ⏳ DNS-validation wait; compose with [`domain.aws.md`](./domain.aws.md) /
  [`web.aws.md`](./web.aws.md)'s proven ACM pattern when needed. The managed endpoint satisfies the contract.
- **REST API (API Gateway v1)** — the realized Amplify backend used it; HTTP API (v2) is cheaper and simpler
  and is this plan's shape. Reach for v1 only for request validation / usage plans / API keys.
- **Multiple routes/functions** — one function, path-routed in-handler, is the honest starter shape; a
  many-function API is this plan run per service (`SVC` knob).
- **VPC attachment, provisioned concurrency, DLQ/destinations, containers** — each a real feature with real
  cost/complexity; name the need first. (Shared **layers** are now first-class here — the `BIND_LAYER` knob, via
  [`lambda-layer.aws.md`](./lambda-layer.aws.md).) Queue-driven retry/DLQ shapes live in `task-runner.aws.md`.
- **WAF on the endpoint** — the denial-of-wallet mitigation for serious public traffic; web.aws.md carries the
  cost-asymmetry discussion.
