# Ephemera — Managed data API on AWS (AppSync GraphQL + aws CLI)

> Self-executing Markdown. The **first binding of a new `data-api` intent** — a managed GraphQL API over a
> document table: generated-feeling CRUD without handler code, **realtime subscriptions for web clients**, and
> **one endpoint speaking three auth modes at once** (user tokens, API key, IAM). This is the recovered shape of
> the classic Amplify `@model` API. The cloud is the source of truth; this file is intent + ledger + audit.
>
> **Provides** `data-api(${API_ID} @ GRAPHQL_URL)` (+ `realtime(WSS_URL)` — subscriptions ride the same API).
> **Requires** `document-table(${TABLE})` from [`document-db.aws.md`](./document-db.aws.md) (the data source —
> discovered, never created here) and `identity(${USER_POOL_ID})` from [`auth.aws.md`](./auth.aws.md) (the
> default auth). Both discovered from the cloud by their deterministic names. Where
> [`service.aws.md`](./service.aws.md) is *hand-rolled routes*, this is *declared data access* — pick by how
> much API you want to own.

## 🤖 Director prompt

Observe before acting; verify each step before advancing; stop at 🔴/💥 for human go; write realized values
back into Live State. The schema and every resolver are **artifacts of this plan** (generated below), and the
schema's auth directives are a **pure function of the auth knobs** — a provider is referenced iff it is
configured, so the same answers always produce the same (valid) schema. Schema upload is async — ⏳ poll
`get-schema-creation-status` to `SUCCESS` before touching resolvers.

> **Status: DOGFOODED LIVE 2026-07-01 (API_KEY=on + IAM_ACCESS=on — the full multi-auth assembly, composed, ~$0).**
> Ran end-to-end with the whole recovered stack standing at once (auth.aws.md pool/client + document-db.aws.md
> table — both Requires-discovered, both torn down after): the **defining contract PASSED, first pass** —
> schema `SUCCESS` with knob-matched directives visible (the `--include-directives` fix proven: they ARE
> stripped without it); a real user's ID token drove `putItem` → `getItem` attribute-identical through the
> APPSYNC_JS resolvers; **unauthenticated → `401`** (pinned — was 401-or-403 pre-dogfood); a **garbage API key
> was rejected while the real one served the same query** — one endpoint, two live auth modes, the recovered
> `assetsAPI` shape working; the key value stayed transient (never printed, never ledgered — the review's
> secret-handling fix proven in practice); API + role tagged; **`delete-graphql-api`'s cascade confirmed**
> (schema/resolvers/data source/keys all gone with it), role/table/pool ownership-checked and absence-verified.
> Bonus: the upstream table ran document-db.aws.md's previously-unrun all-flat path (no GSI/TTL/stream) clean.
> Still unrun: the IAM acceptance leg (SigV4 — named omission), a live WebSocket subscription round-trip
> (consumer's proof), `API_KEY=off`/`IAM_ACCESS=off` knob combos at runtime (schema assembly for all 4 combos
> was verified at review), and §4's IAM-propagation retry (data source attached first try this run).

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

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

- **`aws` CLI v2, authenticated** (`aws sts get-caller-identity` succeeds) + **`curl`** + **`python3`**.
- **Run the code blocks under `bash`** (any ≥3.2; see EPHEMERA.md's shell gotcha).
- **An applied [`document-db.aws.md`](./document-db.aws.md)** — the table is the data source; this plan grants
  AppSync access to it via a service role it creates, but never touches the table itself.
- **An applied [`auth.aws.md`](./auth.aws.md)** — user sign-in is the default auth mode; the acceptance test
  mints a token via that plan's §5.
- **Credential broker (aws-vault / SSO)?** The §1 service-role steps and teardown's role handling are IAM —
  run each via `--no-session` (EPHEMERA.md gotcha, proven live across this fleet).
- **Cost model before you 🔴:** AppSync bills ~**$4 per million** query/mutation operations + per-million
  subscription updates + connection-minutes. Idle is ~$0, but the endpoint is public — the same
  denial-of-wallet posture as any billable URL.

## Intent

Stand up a **managed GraphQL API** over an existing document table: typed CRUD (`getItem` / `listItems` /
`putItem` / `deleteItem`) resolved directly against DynamoDB by AppSync-managed JS resolvers — no Lambda, no
handler code to operate — plus **subscriptions** (clients get pushed `putItem`/`deleteItem` events over
WebSocket) and **multi-auth on one endpoint**: signed-in users by default, a rotating **API key** for
scripts/trigger-style callers, **IAM** for service-to-service — each an independent knob, exactly the
recovered Amplify `assetsAPI` shape.

**Acceptance contract** (defined here — the first `data-api` binding; future siblings must pass it):
1. the **schema is live** and reflects the knobs (types present; auth directives match the enabled providers)
2. an **authorized round-trip**: `putItem` then `getItem` via a real user token returns the same item — *positive*
3. **auth negatives**: an unauthenticated request is rejected; each *disabled* auth mode stays rejected
   (an API-key call must fail when `API_KEY=off`)
4. **(`API_KEY=on`)** the same query succeeds with `x-api-key` — one endpoint, two live modes
5. **subscriptions are declared**: the schema carries `@aws_subscribe` on the mutations *(config-level — a
   live WebSocket round-trip needs a subscription client and is the consuming app's proof; named, not hidden)*

## Provisioning Inputs

| # | Question | Options (closed enum) | Default | Sets | Gates |
|---|----------|-----------------------|---------|------|-------|
| 1 | API key (scripts/automation)? | `off` / `on` | `off` | `API_KEY` | §2 provider + §5 key + acceptance 3/4 |
| 2 | IAM access (service-to-service)? | `off` / `on` | `off` | `IAM_ACCESS` | §2 provider + schema directives |
| 3 | API-key lifetime (days) | number ≤365 | `30` | `API_KEY_DAYS` | §5 `--expires` |
| 4 | API noun | text — `[a-z0-9-]` | `ephemera-data` | `API_NAME_BASE` | `${API_NAME_BASE}-${ENV}` |
| 5 | Environment | `dev` / `stg` / `uat` / `prod` | `dev` | `ENV` | names + `Environment` tag |
| — | upstream table base name | text — document-db.aws.md's `TABLE_BASE` | `items` | `TABLE_BASE` | discovery |
| — | upstream auth base name | text — auth.aws.md's `AUTH_NAME` | `ephemera-auth` | `AUTH_NAME` | discovery |

```yaml
# → written into Live State once resolved (the deterministic input to every step below)
resolved_inputs:
  api_key:      off          # off | on
  iam_access:   off          # off | on
  api_key_days: 30
  api_name_base: ephemera-data
  table_base:   items
  auth_name:    ephemera-auth
  env:          dev
  resolved_by:  <human who confirmed>
  resolved_at:  <timestamp>
```

> **Determinism.** `create-graphql-api` is non-idempotent → §2 observes by name first (list is paginated —
> the helper refuses a partial page rather than trust it). The schema text is **assembled from the knobs**
> below (auth directives appear iff their provider is enabled — a directive for an absent provider fails
> schema validation, so the assembly *is* the correctness mechanism). Resolver code is fixed text. Same
> answers ⇒ same API.

## Live State

```yaml
status:        not-created      # published template - run it to realize state
last_action:   teardown — delete-graphql-api (cascade confirmed) + service role, then the borrowed-for-the-run
               upstreams (table, pool/client) via their own plans; all absence-verified (💥 go: Mark)
last_verified: 2026-07-01 composed dogfood (throwaway, us-west-2, ~$0) — 5-clause contract PASSED first pass;
               two live auth modes on one endpoint; unauthenticated pinned at 401; see the verify table.

resolved_inputs:            # as run 2026-07-01 (🔴 endpoint + 💥 teardown: go recorded, Mark)
  api_key:      on
  iam_access:   on
  api_key_days: 30
  api_name_base: ephemera-data
  table_base:   items
  auth_name:    ephemera-auth
  env:          dev
realized:                    # cleared by teardown
  AWS_REGION:    —
  API_ID:        —            # §2
  GRAPHQL_URL:   —            # §2 (https endpoint)
  WSS_URL:       —            # §2 (realtime endpoint)
  SVC_ROLE_ARN:  —            # §1 (AppSync→DynamoDB service role)
  API_KEY_EXPIRES: —          # §5 (iff api_key=on). ⚠ An AppSync key has NO non-secret id — the `da2-…`
                              #    value IS the credential. NEVER echo or ledger it; record only the expiry
                              #    and fetch the value transiently (list-api-keys) at the moment of use.
  USER_POOL_ID:  —            # discovered (borrowed)
  TABLE:         —            # discovered (borrowed)
```

| ✔ check                            | expected                                            | observed (2026-07-01 dogfood) | result |
|------------------------------------|-----------------------------------------------------|----------|--------|
| schema live, reflects knobs        | types present; directives = enabled providers       | SUCCESS; api_key+iam+cognito directives all present | PASS |
| authorized round-trip              | putItem → getItem via user token, item-identical    | title round-tripped via JS resolvers | PASS |
| unauthenticated rejected (neg)     | no-auth request → 401                               | 401 (pinned) | PASS |
| disabled/garbage mode dead (neg)   | bad credential rejected                             | garbage x-api-key rejected | PASS |
| api-key mode live (iff on)         | same query 200 with x-api-key                       | served; value stayed transient | PASS |
| subscriptions declared             | schema carries @aws_subscribe on both mutations     | present in directive-preserving SDL | PASS |
| tags present                       | API + service role carry `ManagedBy=ephemera`       | both tagged | PASS |
| teardown leaves nothing            | api cascade + role + upstreams absent after 💥      | all absence-verified | PASS |

## TAGS — provenance & cost tags

Tag **on create**: the GraphQL API takes a **JSON map** (`create-graphql-api --tags` → `tags_map`); the service
role takes a **Key/Value list** (`tags_kv`). The table and user pool are **borrowed** — their plans tag them.
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 API_NAME_BASE="${API_NAME_BASE:-ephemera-data}"
export API_KEY="${API_KEY:-off}"           # off | on
export IAM_ACCESS="${IAM_ACCESS:-off}"     # off | on
export API_KEY_DAYS="${API_KEY_DAYS:-30}"
export TABLE_BASE="${TABLE_BASE:-items}"       # document-db.aws.md's noun
export AUTH_NAME="${AUTH_NAME:-ephemera-auth}" # auth.aws.md's noun

printf '%s' "$API_NAME_BASE" | grep -Eq '^[a-z0-9-]+$' || { echo "API_NAME_BASE must be [a-z0-9-]"; exit 1; }
case "$API_KEY"    in off|on) ;; *) echo "API_KEY must be off|on"; exit 1;; esac
case "$IAM_ACCESS" in off|on) ;; *) echo "IAM_ACCESS must be off|on"; exit 1;; esac
case "$ENV"        in dev|stg|uat|prod) ;; *) echo "ENV must be dev|stg|uat|prod"; exit 1;; esac
printf '%s' "$API_KEY_DAYS" | grep -Eq '^[0-9]+$' && [ "$API_KEY_DAYS" -ge 1 ] && [ "$API_KEY_DAYS" -le 365 ] \
  || { echo "API_KEY_DAYS must be 1-365"; exit 1; }
printf '%s' "$TABLE_BASE" | grep -Eq '^[a-z0-9-]+$' || { echo "TABLE_BASE must be [a-z0-9-]"; exit 1; }
printf '%s' "$AUTH_NAME"  | grep -Eq '^[a-z0-9-]+$' || { echo "AUTH_NAME must be [a-z0-9-]"; exit 1; }

API_NAME="${API_NAME_BASE}-${ENV}"
TABLE="${TABLE_BASE}-${ENV}"
SVC_ROLE="${API_NAME}-appsync-ds"
DS_NAME="items_table"                       # data-source names allow [_A-Za-z0-9] only — no hyphens
BUILD="/tmp/${API_NAME}-build"; mkdir -p "$BUILD"
ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"

# single-page-listing guard (API names are not unique; never trust a partial page)
apis_json() {
  local J; J="$(aws appsync list-graphql-apis --region "$AWS_REGION" --max-results 25 --output json)"
  printf '%s' "$J" | grep -q '"nextToken"' \
    && { echo ">25 AppSync APIs — page the listing before trusting name-discovery" >&2; return 1; }
  printf '%s' "$J"
}

# ── TAGS — resolved once (canonical: scripts/tags.sh) ──
PLAN_SOURCE="data-api.aws.md"
PLAN_VERSION="2026-07-01"
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
# the table (document-db.aws.md) — the data source
aws dynamodb describe-table --region "$AWS_REGION" --table-name "$TABLE" >/dev/null \
  || { echo "table ${TABLE} not found — apply document-db.aws.md first"; exit 1; }
# the user pool (auth.aws.md) — the default auth
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; }
echo "discovered: table ${TABLE}, pool ${USER_POOL_ID}"
```
> → Live State: `TABLE`, `USER_POOL_ID` (discovered, NOT created).

## Dependency frontier

```
document-table ── discovered ─┐
identity(pool) ── discovered ─┼─> §1 service role 🟢 (IAM) ─> §2 API 🔴🟢 (+auth providers) ─> §3 schema 🟡⏳
                              │                                                                  │
(API_KEY=on) ─────────────────┴─> §5 api key 🟡 (after §2)          §4 data source + resolvers 🟡 ┴─> §6 ✔
```

Non-negotiable edges: the **service role precedes the data source** (its ARN is a create parameter — and its
trust must exist before AppSync validates it); the **API precedes everything scoped to it** (schema, data
source, resolvers, key); the **schema must reach `SUCCESS` before resolvers attach** (⏳ — attaching to a
type that isn't registered yet 404s). Teardown reverses; `delete-graphql-api` cascades its children.

## 1. Service role — AppSync → the table  🟢  *(IAM — `--no-session` under a broker)*

```bash
cat > "$BUILD/trust.json" <<'JSON'
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"appsync.amazonaws.com"},"Action":"sts:AssumeRole"}]}
JSON
SVC_ROLE_ARN="$(aws iam get-role --role-name "$SVC_ROLE" --query 'Role.Arn' --output text 2>/dev/null)" || \
SVC_ROLE_ARN="$(aws iam create-role --role-name "$SVC_ROLE" \
  --assume-role-policy-document file://"$BUILD/trust.json" --tags $(tags_kv) --query 'Role.Arn' --output text)"
# least-priv: exactly the CRUD the resolvers below perform, on the table + its indexes
aws iam put-role-policy --role-name "$SVC_ROLE" --policy-name "${API_NAME}-ds" --policy-document "{
  \"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"TableCrud\",\"Effect\":\"Allow\",
  \"Action\":[\"dynamodb:GetItem\",\"dynamodb:PutItem\",\"dynamodb:DeleteItem\",\"dynamodb:Query\"],
  \"Resource\":[\"arn:aws:dynamodb:${AWS_REGION}:${ACCOUNT_ID}:table/${TABLE}\",
                \"arn:aws:dynamodb:${AWS_REGION}:${ACCOUNT_ID}:table/${TABLE}/index/*\"]}]}"
```
```bash
# ✔ role + inline grant present
aws iam get-role --role-name "$SVC_ROLE" --query 'Role.RoleName' --output text
aws iam get-role-policy --role-name "$SVC_ROLE" --policy-name "${API_NAME}-ds" --query PolicyName --output text
```
> → Live State: `SVC_ROLE_ARN`.  ⏳ IAM is eventually consistent — §4 retries the data-source create.

## 2. GraphQL API  🔴🟢  *(the public billable endpoint — the gate)*

> 🔴 Human go: a world-reachable GraphQL endpoint billing ~$4/M operations (+ subscription minutes) — the
> denial-of-wallet surface. Default auth is the user pool; `API_KEY`/`IAM_ACCESS` knobs add providers
> **at create** so the schema's directives always match.

```bash
API_ID="$(apis_json | N="$API_NAME" python3 -c 'import sys,json,os;m=[a["apiId"] for a in json.load(sys.stdin).get("graphqlApis",[]) if a.get("name")==os.environ["N"]];print(m[0] if m else "")')"
if [ -z "$API_ID" ]; then
  ADDL='[]'
  [ "$API_KEY" = on ]    && ADDL="$(printf '%s' "$ADDL" | python3 -c 'import sys,json;a=json.load(sys.stdin);a.append({"authenticationType":"API_KEY"});print(json.dumps(a))')"
  [ "$IAM_ACCESS" = on ] && ADDL="$(printf '%s' "$ADDL" | python3 -c 'import sys,json;a=json.load(sys.stdin);a.append({"authenticationType":"AWS_IAM"});print(json.dumps(a))')"
  API_ID="$(aws appsync create-graphql-api --region "$AWS_REGION" --name "$API_NAME" \
    --authentication-type AMAZON_COGNITO_USER_POOLS \
    --user-pool-config "userPoolId=${USER_POOL_ID},awsRegion=${AWS_REGION},defaultAction=ALLOW" \
    --additional-authentication-providers "$ADDL" \
    --tags "$(tags_map)" \
    --query 'graphqlApi.apiId' --output text)"
fi
GRAPHQL_URL="$(aws appsync get-graphql-api --region "$AWS_REGION" --api-id "$API_ID" --query 'graphqlApi.uris.GRAPHQL' --output text)"
WSS_URL="$(aws appsync get-graphql-api --region "$AWS_REGION" --api-id "$API_ID" --query 'graphqlApi.uris.REALTIME' --output text)"
echo "API_ID=${API_ID}"; echo "GRAPHQL_URL=${GRAPHQL_URL}"
```
```bash
# ✔ auth providers reflect the knobs (asserted; None→empty for the omitted-when-empty edge; sorted — order unpledged)
GOT="$(aws appsync get-graphql-api --region "$AWS_REGION" --api-id "$API_ID" \
  --query 'graphqlApi.additionalAuthenticationProviders[].authenticationType' --output text | tr '\t' '\n' | sort | tr '\n' ' ')"
GOT="$(printf '%s' "$GOT" | sed 's/None//g; s/^ *//; s/ *$//')"
WANT=""; [ "$API_KEY" = on ] && WANT="API_KEY"; [ "$IAM_ACCESS" = on ] && WANT="${WANT:+$WANT }AWS_IAM"
WANT="$(printf '%s' "$WANT" | tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/^ *//; s/ *$//')"
[ "$GOT" = "$WANT" ] && echo "auth providers: [${GOT:-none}] (match knobs)" || { echo "providers [${GOT}] ≠ knobs [${WANT}]"; exit 1; }
```
> → Live State: `API_ID`, `GRAPHQL_URL`, `WSS_URL`; `status: creating`.

## 3. Schema — assembled from the knobs  🟡⏳

> The schema **is** the plan's artifact. `@aws_api_key` / `@aws_iam` directives are emitted **iff** their
> provider is on (a directive without its provider fails validation — assembly is the safety). Subscriptions
> ride `@aws_subscribe` — clients on the `REALTIME` endpoint get pushed both mutations. **Design choice,
> named:** under multi-auth the Subscription field keeps only the default (user-pool) auth — API-key/IAM
> callers can read/write but not subscribe; widen its directives if machine subscribers are ever wanted.

```bash
AK="$API_KEY" IA="$IAM_ACCESS" python3 - > "$BUILD/schema.graphql" <<'PY'
import os
extra = ""
if os.environ["AK"] == "on": extra += " @aws_api_key"
if os.environ["IA"] == "on": extra += " @aws_iam"
# with additional providers, opened fields also need the default-auth directive kept explicit
cog = " @aws_cognito_user_pools" if extra else ""
d = cog + extra
print(f"""type Item{d} {{
  pk: String!
  sk: String!
  title: String
  n: Int
}}

type Query {{
  getItem(pk: String!, sk: String!): Item{d}
  listItems(pk: String!): [Item]{d}
}}

type Mutation {{
  putItem(pk: String!, sk: String!, title: String, n: Int): Item{d}
  deleteItem(pk: String!, sk: String!): Item{d}
}}

type Subscription {{
  onItemChange(pk: String): Item @aws_subscribe(mutations: ["putItem", "deleteItem"]){cog}
}}""")
PY
aws appsync start-schema-creation --region "$AWS_REGION" --api-id "$API_ID" \
  --definition fileb://"$BUILD/schema.graphql" >/dev/null
# ⏳ poll to SUCCESS (async; resolvers 404 against an unregistered type)
for i in 1 2 3 4 5 6 7 8 9 10; do
  ST="$(aws appsync get-schema-creation-status --region "$AWS_REGION" --api-id "$API_ID" --query 'status' --output text)"
  if [ "$ST" = "SUCCESS" ]; then break; fi
  if [ "$ST" = "FAILED" ]; then
    aws appsync get-schema-creation-status --region "$AWS_REGION" --api-id "$API_ID" --query 'details' --output text
    exit 1
  fi
  sleep 3
done
[ "$ST" = "SUCCESS" ] && echo "schema: SUCCESS" || { echo "schema stuck (${ST})"; exit 1; }
```
```bash
# ✔ the live schema carries the knob-matched directives + the subscription wiring (asserted)
SDL="$(aws appsync get-introspection-schema --region "$AWS_REGION" --api-id "$API_ID" --format SDL \
  --include-directives /dev/stdout 2>/dev/null || true)"   # directives are STRIPPED without the flag (self-blocking verify otherwise)
printf '%s' "$SDL" | grep -q 'aws_subscribe' && echo "subscriptions declared" || { echo "no @aws_subscribe"; exit 1; }
if [ "$API_KEY" = on ]; then printf '%s' "$SDL" | grep -q 'aws_api_key' && echo "api-key directives present" || { echo "missing api-key directives"; exit 1; }
else printf '%s' "$SDL" | grep -q 'aws_api_key' && { echo "api-key directives present but knob OFF"; exit 1; } || echo "api-key directives absent (matches knob)"; fi
if [ "$IAM_ACCESS" = on ]; then printf '%s' "$SDL" | grep -q 'aws_iam' && echo "iam directives present" || { echo "missing iam directives"; exit 1; }
else printf '%s' "$SDL" | grep -q 'aws_iam' && { echo "iam directives present but knob OFF"; exit 1; } || echo "iam directives absent (matches knob)"; fi
```
> → Live State: schema SUCCESS.

## 4. Data source + resolvers  🟡  *(JS unit resolvers — the no-handler CRUD)*

```bash
# data source (observe first; IAM propagation on a fresh role → brief retry, the fleet's proven idiom)
if ! aws appsync get-data-source --region "$AWS_REGION" --api-id "$API_ID" --name "$DS_NAME" >/dev/null 2>&1; then
  for i in 1 2 3 4 5; do
    if aws appsync create-data-source --region "$AWS_REGION" --api-id "$API_ID" --name "$DS_NAME" \
         --type AMAZON_DYNAMODB --service-role-arn "$SVC_ROLE_ARN" \
         --dynamodb-config "tableName=${TABLE},awsRegion=${AWS_REGION}" >/dev/null 2>/tmp/ds-err; then break; fi
    if [ "$i" = 5 ]; then echo "create-data-source kept failing:"; cat /tmp/ds-err; exit 1; fi
    echo "role not yet assumable — retry ${i}/5"; sleep 5
  done
fi

# one JS resolver per field — fixed artifacts (APPSYNC_JS runtime)
cat > "$BUILD/getItem.js" <<'JS'
import { util } from '@aws-appsync/utils';
export function request(ctx) {
  return { operation: 'GetItem', key: util.dynamodb.toMapValues({ pk: ctx.args.pk, sk: ctx.args.sk }) };
}
export function response(ctx) {
  if (ctx.error) util.error(ctx.error.message, ctx.error.type);
  return ctx.result;
}
JS
cat > "$BUILD/listItems.js" <<'JS'
import { util } from '@aws-appsync/utils';
export function request(ctx) {
  return { operation: 'Query',
           query: { expression: 'pk = :pk', expressionValues: util.dynamodb.toMapValues({ ':pk': ctx.args.pk }) } };
}
export function response(ctx) {
  if (ctx.error) util.error(ctx.error.message, ctx.error.type);
  return ctx.result.items;
}
JS
cat > "$BUILD/putItem.js" <<'JS'
import { util } from '@aws-appsync/utils';
export function request(ctx) {
  const { pk, sk, ...rest } = ctx.args;
  return { operation: 'PutItem', key: util.dynamodb.toMapValues({ pk, sk }),
           attributeValues: util.dynamodb.toMapValues(rest) };
}
export function response(ctx) {
  if (ctx.error) util.error(ctx.error.message, ctx.error.type);
  return ctx.result;
}
JS
cat > "$BUILD/deleteItem.js" <<'JS'
import { util } from '@aws-appsync/utils';
export function request(ctx) {
  return { operation: 'DeleteItem', key: util.dynamodb.toMapValues({ pk: ctx.args.pk, sk: ctx.args.sk }) };
}
export function response(ctx) {
  if (ctx.error) util.error(ctx.error.message, ctx.error.type);
  return ctx.result;
}
JS

attach() {  # attach <Type> <field> <file> — create-or-update (both idempotent by field identity)
  local T="$1" F="$2" C="$3"
  if aws appsync get-resolver --region "$AWS_REGION" --api-id "$API_ID" --type-name "$T" --field-name "$F" >/dev/null 2>&1; then
    aws appsync update-resolver --region "$AWS_REGION" --api-id "$API_ID" --type-name "$T" --field-name "$F" \
      --data-source-name "$DS_NAME" --kind UNIT \
      --runtime name=APPSYNC_JS,runtimeVersion=1.0.0 --code file://"$C" >/dev/null
  else
    aws appsync create-resolver --region "$AWS_REGION" --api-id "$API_ID" --type-name "$T" --field-name "$F" \
      --data-source-name "$DS_NAME" --kind UNIT \
      --runtime name=APPSYNC_JS,runtimeVersion=1.0.0 --code file://"$C" >/dev/null
  fi
  echo "resolver ${T}.${F} attached"
}
attach Query    getItem    "$BUILD/getItem.js"
attach Query    listItems  "$BUILD/listItems.js"
attach Mutation putItem    "$BUILD/putItem.js"
attach Mutation deleteItem "$BUILD/deleteItem.js"
```
```bash
# ✔ all four fields resolve to the table's data source
for TF in "Query getItem" "Query listItems" "Mutation putItem" "Mutation deleteItem"; do
  set -- $TF
  DS="$(aws appsync get-resolver --region "$AWS_REGION" --api-id "$API_ID" --type-name "$1" --field-name "$2" \
    --query 'resolver.dataSourceName' --output text)"
  [ "$DS" = "$DS_NAME" ] || { echo "resolver $1.$2 wrong data source (${DS})"; exit 1; }
done
echo "resolvers: 4/4 on ${DS_NAME}"
```
> → Live State: data source + 4 resolvers.

## 5. API key — iff `API_KEY=on`  🟡  *(⚠ the `da2-…` "id" IS the secret — never echo it, never ledger it)*

```bash
if [ "$API_KEY" = on ]; then
  EXPIRES="$(python3 -c "import time;print(int(time.time())+${API_KEY_DAYS}*86400)")"
  NOW="$(date +%s)"
  # observe: reuse an UNEXPIRED key (expired keys linger in the listing up to 60 days — filter, don't trust [0]).
  # The value is captured silently; nothing below prints it.
  KEY_VAL="$(aws appsync list-api-keys --region "$AWS_REGION" --api-id "$API_ID" \
    --query 'apiKeys[?expires > `'"$NOW"'`] | [0].id' --output text)"
  if [ "$KEY_VAL" = "None" ] || [ -z "$KEY_VAL" ]; then
    aws appsync create-api-key --region "$AWS_REGION" --api-id "$API_ID" \
      --expires "$EXPIRES" --description "ephemera ${API_NAME}" >/dev/null
  fi
  echo "api key present (expires within ${API_KEY_DAYS}d)"   # the value never touches the transcript/ledger
fi
```
```bash
# ✔ an UNEXPIRED key exists iff the knob is on
NOW="$(date +%s)"
N="$(aws appsync list-api-keys --region "$AWS_REGION" --api-id "$API_ID" \
  --query 'length(apiKeys[?expires > `'"$NOW"'`])' --output text)"
if [ "$API_KEY" = on ]; then [ "$N" -ge 1 ] && echo "api key live (unexpired)" || { echo "no live key"; exit 1; }
else [ "$N" = "0" ] && echo "no live api keys (matches knob)" || { echo "live keys exist but knob OFF"; exit 1; }; fi
```
> → Live State: `API_KEY_EXPIRES` only. Rotation = `create-api-key` then `delete-api-key` on the old value
> (fetched transiently). Consumers fetch the value the same way — it is never written down.

## 6. Acceptance verify  ✔  *(pure curl against the live endpoint)*

```bash
# a real user token (auth.aws.md §5: sign-up → admin-confirm → initiate-auth); export ID_TOKEN first
: "${ID_TOKEN:?acceptance needs ID_TOKEN — mint one via auth.aws.md §5 and export it}"
GQ() {  # GQ <extra curl args…> — POST a GraphQL body from $BODY
  curl -s -X POST "$GRAPHQL_URL" -H 'Content-Type: application/json' "$@" -d "$BODY"
}
PK="acctest-$$"

# 2 — authorized round-trip (user token)
BODY="$(PK="$PK" python3 -c 'import json,os;print(json.dumps({"query":"mutation($pk:String!,$sk:String!,$t:String){putItem(pk:$pk,sk:$sk,title:$t){pk sk title}}","variables":{"pk":os.environ["PK"],"sk":"v1","t":"ephemera"}}))')"
OUT="$(GQ -H "Authorization: ${ID_TOKEN}")"
printf '%s' "$OUT" | grep -q '"errors"' && { echo "2a: putItem returned errors"; exit 1; } || true
printf '%s' "$OUT" | grep -q '"putItem"' && echo "2a: putItem OK" || { echo "2a: putItem FAILED"; exit 1; }
BODY="$(PK="$PK" python3 -c 'import json,os;print(json.dumps({"query":"query($pk:String!,$sk:String!){getItem(pk:$pk,sk:$sk){pk sk title}}","variables":{"pk":os.environ["PK"],"sk":"v1"}}))')"
GQ -H "Authorization: ${ID_TOKEN}" | grep -q '"title": *"ephemera"' && echo "2b: getItem round-trip OK" || { echo "2b: FAILED"; exit 1; }

# 3 — negatives: no auth → rejected; disabled modes stay dead
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$GRAPHQL_URL" -H 'Content-Type: application/json' -d "$BODY")"
{ [ "$CODE" = "401" ] || [ "$CODE" = "403" ]; } && echo "3a: unauthenticated → ${CODE}" || { echo "3a: expected 401/403, got ${CODE}"; exit 1; }
if [ "$API_KEY" = off ]; then
  OUT="$(GQ -H 'x-api-key: not-a-real-key')"
  printf '%s' "$OUT" | grep -q '"getItem"' && { echo "3b: api-key path ALIVE while knob off"; exit 1; } || echo "3b: api-key mode dead (matches knob)"
fi

# 4 — api-key mode live (iff on): fetch the key VALUE transiently (never echoed, never ledgered)
if [ "$API_KEY" = on ]; then
  NOW="$(date +%s)"
  KV="$(aws appsync list-api-keys --region "$AWS_REGION" --api-id "$API_ID" \
    --query 'apiKeys[?expires > `'"$NOW"'`] | [0].id' --output text)"   # transient fetch, never printed
  OUT="$(GQ -H "x-api-key: ${KV}")"
  # content assert — a denied key still returns 200 with the field name inside "errors" (fail-open trap)
  printf '%s' "$OUT" | grep -q '"errors"' && { echo "4: api-key request returned errors"; exit 1; } || true
  printf '%s' "$OUT" | grep -q '"title": *"ephemera"' && echo "4: api-key query OK (one endpoint, two live modes)" \
    || { echo "4: api-key query FAILED"; exit 1; }
fi

# cleanup the acceptance item (user token)
BODY="$(PK="$PK" python3 -c 'import json,os;print(json.dumps({"query":"mutation($pk:String!,$sk:String!){deleteItem(pk:$pk,sk:$sk){pk}}","variables":{"pk":os.environ["PK"],"sk":"v1"}}))')"
GQ -H "Authorization: ${ID_TOKEN}" >/dev/null && echo "test item deleted"

# tags (drift)
AT="$(aws appsync list-tags-for-resource --region "$AWS_REGION" \
  --resource-arn "arn:aws:appsync:${AWS_REGION}:${ACCOUNT_ID}:apis/${API_ID}" --output json)"
printf '%s' "$AT" | grep -q '"ManagedBy": *"ephemera"' && echo "api tags ok" || { echo "api untagged"; exit 1; }
RT="$(aws iam list-role-tags --role-name "$SVC_ROLE" --query "Tags[?Key=='ManagedBy'].Value | [0]" --output text)"
[ "$RT" = "ephemera" ] && echo "role tags ok" || { echo "role untagged"; exit 1; }
```
> → Live State: fill the verify rows, `last_verified`, `status: live`. *(Clause 5 — subscriptions — was
> asserted at §3's schema verify; a live WebSocket push is the consuming app's proof.)*

## Update (idempotent reconcile)  🟡

- **Schema change** → regenerate §3's artifact and `start-schema-creation` again (it replaces; poll to
  `SUCCESS`); resolvers survive schema updates that keep their fields.
- **Toggle `API_KEY`/`IAM_ACCESS`** → `update-graphql-api` with the new provider list **and** re-run §3 (the
  directives must follow the providers — the assembly keeps them in lockstep); off → also delete the keys.
- **Resolver logic** → edit the artifact, re-run §4's `attach` (create-or-update by field identity).
- **Key rotation** → §5's note: create new, delete old by id.

## Teardown — observe-first, resumable  💥

> 💥 Human go. `delete-graphql-api` **cascades** — schema, resolvers, data sources, and API keys go with it
> (subscriptions drop as the endpoint dies). The table and user pool are **borrowed and untouched** — deleting
> this API deletes *access*, never data. Ownership-checked; the service role is guarded like every borrowed-
> adjacent IAM artifact in this fleet.

```bash
# observe — re-discover by name (resumable)
API_ID="$(apis_json | N="$API_NAME" python3 -c 'import sys,json,os;m=[a["apiId"] for a in json.load(sys.stdin).get("graphqlApis",[]) if a.get("name")==os.environ["N"]];print(m[0] if m else "")')"
if [ -n "$API_ID" ]; then
  AT="$(aws appsync list-tags-for-resource --region "$AWS_REGION" \
    --resource-arn "arn:aws:appsync:${AWS_REGION}:${ACCOUNT_ID}: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 appsync delete-graphql-api --region "$AWS_REGION" --api-id "$API_ID"
fi
# the service role (ours; --no-session under a broker)
if aws iam get-role --role-name "$SVC_ROLE" >/dev/null 2>&1; then
  RT="$(aws iam list-role-tags --role-name "$SVC_ROLE" --query "Tags[?Key=='ManagedBy'].Value | [0]" --output text 2>&1 || true)"
  [ "$RT" = "ephemera" ] || { echo "role ${SVC_ROLE} lacks our tag — not ours"; exit 1; }
  aws iam delete-role-policy --role-name "$SVC_ROLE" --policy-name "${API_NAME}-ds" 2>/dev/null || true
  aws iam delete-role --role-name "$SVC_ROLE"
fi
```
```bash
# ✔ teardown verify — capture, then assert absence (pipefail-safe)
API_LEFT="$(apis_json | N="$API_NAME" python3 -c 'import sys,json,os;m=[a["apiId"] for a in json.load(sys.stdin).get("graphqlApis",[]) if a.get("name")==os.environ["N"]];print(m[0] if m else "")')"
[ -z "$API_LEFT" ] && echo "api gone" || { echo "api STILL EXISTS"; exit 1; }
OUT="$(aws iam get-role --role-name "$SVC_ROLE" 2>&1 || true)"
printf '%s' "$OUT" | grep -q NoSuchEntity && echo "service role gone" || { echo "role remains (or IAM unreachable)"; exit 1; }
```
> → Live State: `status: gone`, clear realized ids.

## Composition — how this plugs into the fleet

`data-api(${API_ID} @ GRAPHQL_URL)` is the *declared-access* alternative to
[`service.aws.md`](./service.aws.md)'s hand-rolled routes — same upstreams, opposite ownership trade: AppSync
operates the API, you own only schema + resolver expressions. It **Requires**
[`document-db.aws.md`](./document-db.aws.md)'s table (data source; that plan's `STREAM` remains free for other
consumers — subscriptions here are mutation-driven, not stream-driven) and [`auth.aws.md`](./auth.aws.md)'s
pool (default auth; the same minted token works on both this API and service.aws.md's JWT authorizer). The
`REALTIME` endpoint is the AWS analog of [`realtime.cloudflare.md`](./realtime.cloudflare.md)'s slot — pushed
updates for web clients without owning a socket server.

## Deliberately not included

- **An IAM-mode acceptance leg** — proving `IAM_ACCESS=on` end-to-end needs a SigV4-signed call
  (`curl --aws-sigv4 "aws:amz:${AWS_REGION}:appsync"` can do it, both positive and — signed-but-knob-off —
  negative); §3's directive checks cover config-level both ways. Named so the acceptance gap is a decision.
- **A live WebSocket subscription round-trip in acceptance** — needs a subscription client (the AppSync
  realtime protocol over wss), which curl can't speak; config-level assert only (§3), the consuming app proves
  the push. Named so the gap is a decision.
- **Custom domain** (AppSync domain name + ACM) — same seam as service.aws.md's omission; compose with
  [`domain.aws.md`](./domain.aws.md) when needed.
- **Caching** ($/hr instance-backed — the one AppSync feature that bills while idle), **WAF attachment**,
  **merged APIs**, **pipeline resolvers**, **enhanced subscription filtering** — real features, separate
  decisions; name the need first.
- **Stream-driven subscriptions** (table stream → mutation-less push) — subscriptions here fire on mutations
  *through this API*; out-of-band writes don't push. Wiring the table stream to a `putItem`-calling function
  closes that loop — a compose note, not a hidden limitation.
- **Amplify-style codegen / DataStore** — client-side concerns; the schema is the contract, generate clients
  as you like.
