#!/bin/sh # Gate Actions: scope, safe retries, recovery of a lost answer, exact approval, revocation and expiry in Demo Desk. # Creates one free project, needs only curl, keeps keys in memory only (no files). set -e G=https://gate.odilelabs.com j() { sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p"; } http() { RESPONSE=$(curl -sS -i "$@") STATUS=$(printf '%s\n' "$RESPONSE" | tr -d '\r' | awk '/^HTTP\//{s=$2} END{print s}') REFUSED=$(printf '%s\n' "$RESPONSE" | tr -d '\r' | awk 'tolower($1)=="x-gate-refused:"{r=$2} END{print r}') OUTCOME=$(printf '%s\n' "$RESPONSE" | tr -d '\r' | awk 'tolower($1)=="x-odile-outcome:"{r=$2} END{print r}') BODY=$(printf '%s\n' "$RESPONSE" | tr -d '\r' | sed '1,/^$/d') printf ' HTTP %s x-gate-refused=%s x-odile-outcome=%s\n' "$STATUS" "$REFUSED" "$OUTCOME" } expect() { REASON=$REFUSED if [ -z "$REASON" ]; then REASON=$OUTCOME; fi [ "$STATUS" = "$1" ] && [ "$REASON" = "$2" ] || { echo " Unexpected result; stopping."; exit 1; } } call() { METHOD=$1; PATHNAME=$2; shift 2 http -X "$METHOD" "$G/g/$PID$PATHNAME" -H "Authorization: Bearer $KEY" -H "x-odile-run: $TOKEN" "$@" } grant() { http -X POST "$G/api/projects/$PID/runs" -H "Authorization: Bearer $ADMIN" -H 'content-type: application/json' \ -d "{\"app\":\"$APP\",\"scope\":{\"customer\":\"A\"},\"actions\":[{\"rule\":\"PATCH /customers/{customer}/tickets/:id\",\"max\":2},\"GET /customers/{customer}/tickets/:id\",{\"rule\":\"POST /customers/{customer}/messages\",\"approval\":true},\"GET /customers/{customer}/messages\",{\"rule\":\"POST /customers/{customer}/bookings\",\"check\":\"GET /customers/{customer}/bookings?idempotency_key={key}\",\"idempotent\":true},\"GET /customers/{customer}/bookings\"],\"ttl_seconds\":$1}" expect 201 TOKEN=$(printf '%s\n' "$BODY" | j token) RUN=$(printf '%s\n' "$BODY" | j run_id) [ -n "$TOKEN" ] && [ -n "$RUN" ] || exit 1 } echo '1. Create a free Demo Desk project; no card or signup.' http -X POST "$G/api/projects" -H 'content-type: application/json' -d '{"name":"Gate Actions quickstart","upstream":"https://sandbox.gate.odilelabs.com"}' expect 201 PID=$(printf '%s\n' "$BODY" | j project_id) ADMIN=$(printf '%s\n' "$BODY" | j admin_key) [ -n "$PID" ] && [ -n "$ADMIN" ] || exit 1 echo '2. Register support-agent; its allow-list is the outer bound.' http -X POST "$G/api/projects/$PID/apps" -H "Authorization: Bearer $ADMIN" -H 'content-type: application/json' \ -d '{"name":"support-agent","routes":["GET /customers/:cid/tickets/:id","PATCH /customers/:cid/tickets/:id","DELETE /customers/:cid/tickets/:id","POST /customers/:cid/messages","GET /customers/:cid/messages","POST /customers/:cid/bookings","GET /customers/:cid/bookings","GET /export"]}' expect 201 KEY=$(printf '%s\n' "$BODY" | j key) APP=$(printf '%s\n' "$BODY" | j id) [ -n "$KEY" ] && [ -n "$APP" ] || exit 1 echo '3. Grant a 600-second run for customer A.' grant 600 echo '4. Allowed: PATCH customer A ticket 1.' call PATCH /customers/A/tickets/1 -H 'content-type: application/json' -H 'Idempotency-Key: ticket-1' -d '{"priority":"high"}' expect 200 echo '5. Refused: another customer, deletion, and export.' call PATCH /customers/B/tickets/4 -H 'Idempotency-Key: wrong-customer' -d '{"priority":"high"}' expect 403 action_not_in_run call DELETE /customers/A/tickets/1 -H 'Idempotency-Key: delete-1' expect 403 action_not_in_run call GET /export expect 403 action_not_in_run echo '6. Safe retry: the same key gets already_executed.' call PATCH /customers/A/tickets/1 -H 'content-type: application/json' -H 'Idempotency-Key: ticket-1' -d '{"priority":"high"}' expect 409 already_executed call GET /customers/A/tickets/1 expect 200 WRITES=$(printf '%s\n' "$BODY" | sed -n 's/.*"writes":\([0-9][0-9]*\).*/\1/p') echo " Ticket writes: $WRITES (must be 1)" [ "$WRITES" = 1 ] || exit 1 echo '7. Exact approval: hold a message, then approve that exact call.' MESSAGE='{"text":"We are reviewing your ticket."}' call POST /customers/A/messages -H 'content-type: application/json' -H 'Idempotency-Key: message-1' -d "$MESSAGE" expect 202 approval_required ACTION=$(printf '%s\n' "$BODY" | j action_id) [ -n "$ACTION" ] || exit 1 echo " Held action_id: $ACTION" echo ' This script plays the approver with the admin key. In production a person does this in the dashboard with an approver key.' http -X POST "$G/api/projects/$PID/actions/$ACTION/approve" -H "Authorization: Bearer $ADMIN" -H 'content-type: application/json' -d '{}' expect 200 call POST /customers/A/messages -H 'content-type: application/json' -H 'Idempotency-Key: message-1' -H "x-odile-action: $ACTION" -d '{"text":"Changed message"}' expect 409 approval_mismatch call POST /customers/A/messages -H 'content-type: application/json' -H 'Idempotency-Key: message-1' -H "x-odile-action: $ACTION" -d "$MESSAGE" expect 201 call POST /customers/A/messages -H 'content-type: application/json' -H 'Idempotency-Key: message-1' -H "x-odile-action: $ACTION" -d "$MESSAGE" expect 409 already_executed call GET /customers/A/messages expect 200 echo " Messages (sent once): $BODY" echo '8. Lost answer: Demo Desk makes a booking, then the reply is lost (504).' call POST /customers/A/bookings -H 'content-type: application/json' -H 'Idempotency-Key: booking-1' -H 'x-demo-fail: after-commit' -d '{"slot":"2026-10-25T11:30"}' expect 504 '' call POST /customers/A/bookings -H 'content-type: application/json' -H 'Idempotency-Key: booking-1' -d '{"slot":"2026-10-25T11:30"}' expect 409 outcome_unknown echo ' Ask Gate what really happened; it runs the check the run was granted with.' call POST /_gate/recover -H 'Idempotency-Key: booking-1' expect 200 executed echo " $BODY" call POST /customers/A/bookings -H 'content-type: application/json' -H 'Idempotency-Key: booking-1' -d '{"slot":"2026-10-25T11:30"}' expect 409 already_executed call GET /customers/A/bookings expect 200 BOOKINGS=$(printf '%s\n' "$BODY" | grep -o '"id":"bk_' | wc -l | tr -d ' ') echo " Bookings: $BOOKINGS (must be 1)" [ "$BOOKINGS" = 1 ] || exit 1 echo '9. Revoke the run; its next call is refused.' http -X POST "$G/api/projects/$PID/runs/$RUN/revoke" -H "Authorization: Bearer $ADMIN" expect 200 call GET /customers/A/tickets/1 expect 403 run_revoked echo '10. Expiry: sandbox-only 5-second run, wait 6 seconds.' grant 5 sleep 6 call GET /customers/A/tickets/1 expect 403 run_expired echo '11. Latest audit rows (CSV):' curl -sS "$G/api/projects/$PID/audit.csv" -H "Authorization: Bearer $ADMIN" | awk 'NR<=6' echo "Dashboard: $G/dashboard (project $PID; keys stayed in this process)." echo 'Next: point upstream at your own API'