From 10c64771adb4c7c7a7f328177a01bea1d7751408 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Tue, 6 Oct 2026 03:31:55 -0700 Subject: [PATCH 1/3] Implement the Platform API in Go: every operation, workers, MCP, CI/CD, Terraform - All 98 operations in docs/openapi.yaml (routes generated by oapi-codegen; bodies hand-written for exact nulls/PATCH presence), one transaction per request with RLS test/live isolation, idempotency, rate limits, ETag, audit + webhook outbox in the same transaction, access.* flip events. - Postgres schema (migrations/0001_init.sql), pgx and RDS Data API drivers behind one interface, in-repo migration runner. - Webhook delivery (signing, retries, health), Stripe billing + sync, scheduled jobs (sweep, erasure cascade, archival, seat sync, cleanup), stateless MCP server generated from the spec. - Lambda entry points, devserver, admin bootstrap, Quickstart script. - Integration + contract tests against real Postgres (every response validated against openapi.yaml); unit tests; race-clean; lint-clean. - ci.yml / deploy.yml / rollback.yml; Terraform bootstrap + prod. - openapi.yaml drift fixes found by contract tests (nullable fields, RE2-incompatible slug pattern, undeclared documented filters). Co-Authored-By: Claude Opus 5.5 --- .env.example | 48 + .github/workflows/ci.yml | 131 + .github/workflows/deploy.yml | 140 + .github/workflows/rollback.yml | 43 + .gitignore | 9 + .golangci.yml | 23 + Makefile | 97 + cmd/admin/main.go | 47 + cmd/api/main.go | 24 + cmd/devserver/main.go | 71 + cmd/jobs/main.go | 47 + cmd/mcp/main.go | 28 + cmd/mcpgen/main.go | 265 + cmd/migrate/main.go | 69 + cmd/stripe-catalog/main.go | 91 + cmd/stripe-webhook/main.go | 27 + cmd/webhook-worker/main.go | 26 + docker-compose.yml | 40 + docker/postgres-init.sql | 7 + docs/openapi.yaml | 20 +- go.mod | 74 + go.sum | 295 + infra/terraform/bootstrap/.terraform.lock.hcl | 26 + infra/terraform/bootstrap/main.tf | 293 + infra/terraform/bootstrap/outputs.tf | 6 + infra/terraform/bootstrap/variables.tf | 27 + .../terraform/modules/lambda_function/main.tf | 109 + infra/terraform/prod/.terraform.lock.hcl | 47 + infra/terraform/prod/main.tf | 581 ++ infra/terraform/prod/outputs.tf | 8 + infra/terraform/prod/variables.tf | 69 + infra/terraform/prod/versions.tf | 19 + internal/access/access.go | 207 + internal/access/access_test.go | 165 + internal/api/gen/gen.go | 8208 +++++++++++++++++ internal/api/gen/generate.go | 5 + internal/api/gen/oapi-codegen.yaml | 12 + internal/auth/auth_test.go | 79 + internal/auth/breached.txt | 1201 +++ internal/auth/encrypt.go | 109 + internal/auth/jwt.go | 232 + internal/auth/password.go | 132 + internal/auth/totp.go | 113 + internal/billing/billing.go | 177 + internal/billing/webhook.go | 114 + internal/core/apikeys.go | 423 + internal/core/applications.go | 780 ++ internal/core/audit.go | 179 + internal/core/auth_flows.go | 968 ++ internal/core/auth_session.go | 604 ++ internal/core/auth_test.go | 306 + internal/core/bootstrap.go | 61 + internal/core/call.go | 389 + internal/core/common.go | 350 + internal/core/destructive.go | 69 + internal/core/entitlements.go | 919 ++ internal/core/entitlements_test.go | 201 + internal/core/export.go | 193 + internal/core/mail.go | 84 + internal/core/organizations.go | 917 ++ internal/core/organizations_test.go | 169 + internal/core/principal.go | 226 + internal/core/profiles.go | 785 ++ internal/core/quickstart_test.go | 98 + internal/core/ratelimit.go | 115 + internal/core/resolve.go | 343 + internal/core/roles.go | 753 ++ internal/core/server.go | 220 + internal/core/system.go | 601 ++ internal/core/tenants.go | 680 ++ internal/core/users.go | 616 ++ internal/core/users_create.go | 99 + internal/core/webhooks.go | 576 ++ internal/core/webhooks_test.go | 400 + internal/db/dataapi/dataapi.go | 214 + internal/db/dataapi/dataapi_test.go | 55 + internal/db/db.go | 267 + internal/db/db_test.go | 35 + internal/db/migrate/migrate.go | 187 + internal/db/migrate/migrate_test.go | 35 + internal/db/pg/pg.go | 130 + internal/email/email.go | 120 + internal/httpx/cursor.go | 143 + internal/httpx/errors.go | 154 + internal/httpx/httpx_test.go | 53 + internal/httpx/json.go | 204 + internal/ids/ids.go | 88 + internal/jobs/jobs.go | 221 + internal/mcp/mcp_test.go | 123 + internal/mcp/server.go | 420 + internal/mcp/tools.json | 5182 +++++++++++ internal/platform/config/config.go | 187 + internal/platform/lambdahttp/lambdahttp.go | 91 + .../platform/lambdahttp/lambdahttp_test.go | 30 + internal/platform/wire/db.go | 43 + internal/platform/wire/deps.go | 182 + internal/schema/schema.go | 350 + internal/schema/schema_test.go | 69 + internal/testenv/testenv.go | 516 ++ internal/webhooks/notifier.go | 61 + internal/webhooks/sign_test.go | 24 + internal/webhooks/webhooks.go | 433 + migrations/0001_init.sql | 650 ++ migrations/embed.go | 10 + scripts/quickstart.sh | 85 + 105 files changed, 36041 insertions(+), 6 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/rollback.yml create mode 100644 .golangci.yml create mode 100644 Makefile create mode 100644 cmd/admin/main.go create mode 100644 cmd/api/main.go create mode 100644 cmd/devserver/main.go create mode 100644 cmd/jobs/main.go create mode 100644 cmd/mcp/main.go create mode 100644 cmd/mcpgen/main.go create mode 100644 cmd/migrate/main.go create mode 100644 cmd/stripe-catalog/main.go create mode 100644 cmd/stripe-webhook/main.go create mode 100644 cmd/webhook-worker/main.go create mode 100644 docker-compose.yml create mode 100644 docker/postgres-init.sql create mode 100644 go.mod create mode 100644 go.sum create mode 100644 infra/terraform/bootstrap/.terraform.lock.hcl create mode 100644 infra/terraform/bootstrap/main.tf create mode 100644 infra/terraform/bootstrap/outputs.tf create mode 100644 infra/terraform/bootstrap/variables.tf create mode 100644 infra/terraform/modules/lambda_function/main.tf create mode 100644 infra/terraform/prod/.terraform.lock.hcl create mode 100644 infra/terraform/prod/main.tf create mode 100644 infra/terraform/prod/outputs.tf create mode 100644 infra/terraform/prod/variables.tf create mode 100644 infra/terraform/prod/versions.tf create mode 100644 internal/access/access.go create mode 100644 internal/access/access_test.go create mode 100644 internal/api/gen/gen.go create mode 100644 internal/api/gen/generate.go create mode 100644 internal/api/gen/oapi-codegen.yaml create mode 100644 internal/auth/auth_test.go create mode 100644 internal/auth/breached.txt create mode 100644 internal/auth/encrypt.go create mode 100644 internal/auth/jwt.go create mode 100644 internal/auth/password.go create mode 100644 internal/auth/totp.go create mode 100644 internal/billing/billing.go create mode 100644 internal/billing/webhook.go create mode 100644 internal/core/apikeys.go create mode 100644 internal/core/applications.go create mode 100644 internal/core/audit.go create mode 100644 internal/core/auth_flows.go create mode 100644 internal/core/auth_session.go create mode 100644 internal/core/auth_test.go create mode 100644 internal/core/bootstrap.go create mode 100644 internal/core/call.go create mode 100644 internal/core/common.go create mode 100644 internal/core/destructive.go create mode 100644 internal/core/entitlements.go create mode 100644 internal/core/entitlements_test.go create mode 100644 internal/core/export.go create mode 100644 internal/core/mail.go create mode 100644 internal/core/organizations.go create mode 100644 internal/core/organizations_test.go create mode 100644 internal/core/principal.go create mode 100644 internal/core/profiles.go create mode 100644 internal/core/quickstart_test.go create mode 100644 internal/core/ratelimit.go create mode 100644 internal/core/resolve.go create mode 100644 internal/core/roles.go create mode 100644 internal/core/server.go create mode 100644 internal/core/system.go create mode 100644 internal/core/tenants.go create mode 100644 internal/core/users.go create mode 100644 internal/core/users_create.go create mode 100644 internal/core/webhooks.go create mode 100644 internal/core/webhooks_test.go create mode 100644 internal/db/dataapi/dataapi.go create mode 100644 internal/db/dataapi/dataapi_test.go create mode 100644 internal/db/db.go create mode 100644 internal/db/db_test.go create mode 100644 internal/db/migrate/migrate.go create mode 100644 internal/db/migrate/migrate_test.go create mode 100644 internal/db/pg/pg.go create mode 100644 internal/email/email.go create mode 100644 internal/httpx/cursor.go create mode 100644 internal/httpx/errors.go create mode 100644 internal/httpx/httpx_test.go create mode 100644 internal/httpx/json.go create mode 100644 internal/ids/ids.go create mode 100644 internal/jobs/jobs.go create mode 100644 internal/mcp/mcp_test.go create mode 100644 internal/mcp/server.go create mode 100644 internal/mcp/tools.json create mode 100644 internal/platform/config/config.go create mode 100644 internal/platform/lambdahttp/lambdahttp.go create mode 100644 internal/platform/lambdahttp/lambdahttp_test.go create mode 100644 internal/platform/wire/db.go create mode 100644 internal/platform/wire/deps.go create mode 100644 internal/schema/schema.go create mode 100644 internal/schema/schema_test.go create mode 100644 internal/testenv/testenv.go create mode 100644 internal/webhooks/notifier.go create mode 100644 internal/webhooks/sign_test.go create mode 100644 internal/webhooks/webhooks.go create mode 100644 migrations/0001_init.sql create mode 100644 migrations/embed.go create mode 100755 scripts/quickstart.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7777442 --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# Local configuration for `make run` and friends. Copy to .env; every value +# here is a safe local default. Production reads the same variables from +# Lambda configuration and Secrets Manager (APP_SECRET_ARN). + +APP_ENV=local +LISTEN_ADDR=:8080 + +# Database: pgx against docker-compose Postgres (`make up`). In AWS, +# DATABASE_BACKEND=dataapi with DB_CLUSTER_ARN / DB_SECRET_ARN / DB_NAME. +DATABASE_BACKEND=pg +DATABASE_URL=postgres://substratal:substratal@localhost:55432/substratal?sslmode=disable + +# Public identity. ISSUER is the JWT iss; keep the production value so +# tokens look like production ones. +ISSUER=https://api.substratalapps.com +PUBLIC_BASE_URL=http://localhost:8080 +DASHBOARD_URL=http://localhost:3000 +AUTHORIZE_URL=https://auth.substratalapps.com/authorize +CORS_ALLOWED_ORIGINS=http://localhost:3000 + +# Signing and encryption. Empty KMS ids = local keys: an RSA key generated +# into .dev/ on first run, and a fixed development AES key. +KMS_SIGNING_KEY_ID= +KMS_DATA_KEY_ID= +LOCAL_SIGNING_KEY_FILE=.dev/signing-key.pem +CURSOR_SECRET=local-development-cursor-secret + +# Email: Mailpit catches everything; read it at http://localhost:58025. +EMAIL_BACKEND=smtp +SMTP_ADDR=localhost:51025 +EMAIL_FROM=Substratal + +# Webhooks: allow http://localhost receivers locally (never in production). +WEBHOOKS_ALLOW_PRIVATE=true + +# Stripe test mode (optional; billing endpoints return 503 without it). +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= + +# LocalStack, for anything pointed at AWS APIs locally. +AWS_ENDPOINT_URL=http://localhost:4566 +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=test +AWS_SECRET_ACCESS_KEY=test + +# `make seed` / `make quickstart` +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=local admin passphrase diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6d404ee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,131 @@ +# CI for every push to dev (and feature/*, hotfix/*) and every PR into main +# (README → Branches, CI, and deployment → What runs on dev). Nothing here +# touches AWS except the read-only terraform plan on PRs into main. +name: CI + +on: + push: + branches: [dev, "feature/**", "hotfix/**"] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: postgres + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U postgres" --health-interval 2s --health-timeout 3s --health-retries 30 + env: + # The API and tests connect as a non-superuser role, as they do + # locally: a superuser bypasses Row-Level Security. + TEST_DATABASE_URL: postgres://substratal:substratal@localhost:5432/postgres?sslmode=disable + DATABASE_URL: postgres://substratal:substratal@localhost:5432/substratal?sslmode=disable + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - name: Create the database role + env: + PGPASSWORD: postgres + run: psql -h localhost -U postgres -v ON_ERROR_STOP=1 -f docker/postgres-init.sql + + - name: Format and vet + run: make fmt-check && go vet ./... + + - uses: golangci/golangci-lint-action@v9 + with: + version: latest + + - name: Generated code is current + run: make gen-check + + - name: Unit, integration, and contract tests + run: go test -count=1 -race ./... + + - name: Quickstart against the devserver + env: + EMAIL_BACKEND: log + LISTEN_ADDR: ":8080" + ADMIN_EMAIL: ci-admin@example.com + ADMIN_PASSWORD: ci admin passphrase + run: | + go run ./cmd/migrate + go run ./cmd/admin bootstrap -email "$ADMIN_EMAIL" + go build -o /tmp/devserver ./cmd/devserver + /tmp/devserver > /tmp/devserver.log 2>&1 & + for i in $(seq 1 60); do curl -sf localhost:8080/v1/health && break; sleep 0.5; done + SUBSTRATAL_API=http://localhost:8080/v1 scripts/quickstart.sh || (cat /tmp/devserver.log; exit 1) + + - name: Build Lambda artifacts + run: make build + + terraform: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: hashicorp/setup-terraform@v4 + - run: terraform fmt -check -recursive infra/terraform + - run: make tf-validate + + docs: + runs-on: ubuntu-latest + defaults: + run: + working-directory: docs + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.2" + bundler-cache: true + working-directory: docs + - run: bundle exec jekyll build + + # The one thing to read before merging a release: exactly what the merge + # will change in AWS. Read-only role; skipped until Phase 0 setup exists. + plan: + if: github.event_name == 'pull_request' && vars.AWS_PLAN_ROLE_ARN != '' + needs: [test, terraform] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + pull-requests: write + steps: + - uses: actions/checkout@v7 + - uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_PLAN_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + - uses: hashicorp/setup-terraform@v4 + - name: terraform plan + id: plan + working-directory: infra/terraform/prod + run: | + terraform init -input=false -backend-config="bucket=${{ vars.TF_STATE_BUCKET }}" + terraform plan -input=false -no-color -lock=false \ + -var "artifact_bucket=${{ vars.ARTIFACT_BUCKET }}" -var "release=${{ github.event.pull_request.head.sha }}" \ + -var "domain_name=${{ vars.API_DOMAIN }}" > plan.txt 2>&1 || (cat plan.txt; exit 1) + tail -c 60000 plan.txt > plan-short.txt + - uses: actions/github-script@v9 + with: + script: | + const fs = require('fs'); + const plan = fs.readFileSync('infra/terraform/prod/plan-short.txt', 'utf8'); + await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, + body: "### terraform plan (production)\n\nThis is exactly what merging will change in AWS.\n\n
Plan\n\n```\n" + plan + "\n```\n
" }); diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..0935839 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,140 @@ +# Every merge into main deploys production, and only that way (README → +# What a merge into main does). The deploy role's OIDC trust policy accepts +# only this repository, the main branch, and the production environment, +# so nothing else can obtain these credentials, whatever its YAML says. +name: Deploy + +on: + push: + branches: [main] + paths-ignore: ["docs/**", "**.md", ".github/workflows/docs.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: production + cancel-in-progress: false + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: postgres + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U postgres" --health-interval 2s --health-timeout 3s --health-retries 30 + env: + TEST_DATABASE_URL: postgres://substratal:substratal@localhost:5432/postgres?sslmode=disable + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - env: + PGPASSWORD: postgres + run: psql -h localhost -U postgres -v ON_ERROR_STOP=1 -f docker/postgres-init.sql + # The merge result itself, not just the two sides of it. + - run: make fmt-check && go vet ./... && make gen-check && go test -count=1 ./... + + deploy: + needs: test + runs-on: ubuntu-latest + environment: production + permissions: + contents: read + id-token: write + env: + RELEASE: ${{ github.sha }} + steps: + - name: Phase 0 setup exists + id: ready + run: | + if [ -z "${{ vars.AWS_DEPLOY_ROLE_ARN }}" ]; then + echo "::notice::AWS isn't bootstrapped yet (see README → One-time setup); skipping the production deploy." + echo "ready=false" >> "$GITHUB_OUTPUT" + else + echo "ready=true" >> "$GITHUB_OUTPUT" + fi + + - if: steps.ready.outputs.ready == 'true' + uses: actions/checkout@v7 + + - if: steps.ready.outputs.ready == 'true' + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - if: steps.ready.outputs.ready == 'true' + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + - if: steps.ready.outputs.ready == 'true' + uses: hashicorp/setup-terraform@v4 + with: + terraform_wrapper: false + + - name: Build and upload artifacts + if: steps.ready.outputs.ready == 'true' + run: | + make build + for f in api mcp stripe-webhook webhook-worker jobs; do + aws s3 cp "dist/$f.zip" "s3://${{ vars.ARTIFACT_BUCKET }}/$RELEASE/$f.zip" --only-show-errors + done + + - name: terraform init + if: steps.ready.outputs.ready == 'true' + working-directory: infra/terraform/prod + run: terraform init -input=false -backend-config="bucket=${{ vars.TF_STATE_BUCKET }}" + + # Migrations run before new code ships. Safe because every migration + # is expand-only against the running release (README → Migrations). + - name: Migrate (existing cluster) + id: migrate + if: steps.ready.outputs.ready == 'true' + working-directory: infra/terraform/prod + run: | + ARN=$(terraform output -raw db_cluster_arn 2>/dev/null || true) + SECRET=$(terraform output -raw db_secret_arn 2>/dev/null || true) + if [ -n "$ARN" ] && [ -n "$SECRET" ]; then + cd ../../.. && DATABASE_BACKEND=dataapi DB_CLUSTER_ARN="$ARN" DB_SECRET_ARN="$SECRET" go run ./cmd/migrate + echo "done=true" >> "$GITHUB_OUTPUT" + else + echo "done=false" >> "$GITHUB_OUTPUT" # first deploy: the cluster doesn't exist yet + fi + + - name: terraform apply + if: steps.ready.outputs.ready == 'true' + working-directory: infra/terraform/prod + run: | + terraform apply -input=false -auto-approve \ + -var "artifact_bucket=${{ vars.ARTIFACT_BUCKET }}" -var "release=$RELEASE" \ + -var "domain_name=${{ vars.API_DOMAIN }}" + + - name: Migrate (first deploy) + if: steps.ready.outputs.ready == 'true' && steps.migrate.outputs.done == 'false' + working-directory: infra/terraform/prod + run: | + ARN=$(terraform output -raw db_cluster_arn) SECRET=$(terraform output -raw db_secret_arn) + cd ../../.. && DATABASE_BACKEND=dataapi DB_CLUSTER_ARN="$ARN" DB_SECRET_ARN="$SECRET" go run ./cmd/migrate + + - name: Smoke test + if: steps.ready.outputs.ready == 'true' + run: | + BASE="https://${{ vars.API_DOMAIN }}" + for i in $(seq 1 30); do + code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/v1/health") && [ "$code" = 200 ] && break; sleep 5 + done + [ "$code" = 200 ] || { echo "::error::health check failed ($code)"; exit 1; } + curl -sf "$BASE/.well-known/jwks.json" | grep -q '"keys"' + if [ -n "${{ secrets.SMOKE_API_KEY }}" ]; then + # A dedicated smoke-test User and Application seeded in production. + curl -sf "$BASE/v1/users/${{ vars.SMOKE_USER_ID }}/apps/${{ vars.SMOKE_APP_ID }}/effective-permissions" \ + -H "Authorization: Bearer ${{ secrets.SMOKE_API_KEY }}" | grep -q '"allowed":true' + fi diff --git a/.github/workflows/rollback.yml b/.github/workflows/rollback.yml new file mode 100644 index 0000000..df028fd --- /dev/null +++ b/.github/workflows/rollback.yml @@ -0,0 +1,43 @@ +# Emergency rollback (README → Rollback): move each function's live alias +# back to its previous published version, in seconds, without Terraform. +# Afterwards, land a revert of the bad merge on main so Terraform's view +# and reality match again. +name: Rollback + +on: + workflow_dispatch: + inputs: + functions: + description: Functions to roll back + default: "api mcp stripe-webhook webhook-worker jobs" + +permissions: + contents: read + id-token: write + +concurrency: + group: production + cancel-in-progress: false + +jobs: + rollback: + runs-on: ubuntu-latest + environment: production + steps: + - uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + - name: Point live at the previous version + run: | + for f in ${{ inputs.functions }}; do + name="substratal-prod-$f" + current=$(aws lambda get-alias --function-name "$name" --name live --query FunctionVersion --output text) + previous=$(aws lambda list-versions-by-function --function-name "$name" \ + --query "Versions[?Version!='\$LATEST' && to_number(Version) < \`$current\`].Version | [-1]" --output text) + if [ -z "$previous" ] || [ "$previous" = "None" ]; then + echo "::warning::$name has no version before $current"; continue + fi + aws lambda update-alias --function-name "$name" --name live --function-version "$previous" >/dev/null + echo "$name: live $current → $previous" + done diff --git a/.gitignore b/.gitignore index dc804d8..4c78664 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,12 @@ docs/Gemfile.lock .DS_Store docs/_data/versions.yml + +# API +.env +.dev/ +dist/ +*.zip +infra/terraform/**/.terraform/ +infra/terraform/**/*.tfstate* +infra/terraform/**/.terraform.lock.hcl.bak diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..3960975 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,23 @@ +version: "2" +linters: + default: standard + settings: + errcheck: + exclude-functions: + - (net/http.ResponseWriter).Write + - (*encoding/json.Encoder).Encode + staticcheck: + checks: + - all + # Style-only quick fixes. Explicit embedded selectors are deliberate + # where a row type shadows a field (x.entitlement.version). + - -QF1001 + - -QF1003 + - -QF1008 + - -ST1000 + - -ST1003 + - -ST1020 + - -ST1021 + - -ST1022 +run: + timeout: 5m diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5c062b8 --- /dev/null +++ b/Makefile @@ -0,0 +1,97 @@ +# Local development and CI entry points (README → Local development). +# `make check` runs exactly what .github/workflows/ci.yml runs. + +SHELL := /bin/bash +-include .env +export + +FUNCTIONS := api mcp stripe-webhook webhook-worker jobs +GOFLAGS_LAMBDA := CGO_ENABLED=0 GOOS=linux GOARCH=arm64 + +.PHONY: help up down reset migrate migration seed run gen gen-check test test-integration test-contract \ + quickstart lint fmt-check check build tf-fmt tf-validate tf-plan job jobs stripe-listen stripe-catalog + +help: ## List targets + @grep -E '^[a-z-]+:.*## ' $(MAKEFILE_LIST) | awk -F':.*## ' '{printf " %-18s %s\n", $$1, $$2}' + +up: ## Start Postgres, LocalStack, and Mailpit + docker compose up -d --wait postgres + docker compose up -d localstack mailpit + +down: ## Stop local services (data is kept) + docker compose down + +reset: ## Wipe local data and start fresh + docker compose down -v + $(MAKE) up migrate + +migrate: ## Apply migrations to local Postgres + go run ./cmd/migrate + +migration: ## Create the next migration: make migration name=add_x + go run ./cmd/migrate -new $(name) + +seed: ## Bootstrap a local superadmin (ADMIN_EMAIL / ADMIN_PASSWORD) + ADMIN_PASSWORD="$(ADMIN_PASSWORD)" go run ./cmd/admin bootstrap -email "$(ADMIN_EMAIL)" + +run: ## Run the devserver on LISTEN_ADDR (default :8080) + go run ./cmd/devserver + +gen: ## Regenerate code from docs/openapi.yaml (routes + MCP tools) + go generate ./internal/api/gen + go run ./cmd/mcpgen + +gen-check: gen ## Fail if generated code is stale + @git diff --exit-code -- internal/api/gen internal/mcp/tools.json || \ + (echo "generated code is stale: run 'make gen' and commit the result" >&2; exit 1) + +test: ## Unit tests (no containers) + go test ./internal/access/... ./internal/schema/... ./internal/auth/... ./internal/db/... ./internal/httpx/... + +test-integration: ## Integration + contract tests against Postgres (needs `make up`) + go test -count=1 ./... + +test-contract: test-integration ## Contract tests run inside the integration suite (every response is validated) + +quickstart: ## docs/quickstart.md as a script, against the running devserver + SUBSTRATAL_API=$${SUBSTRATAL_API:-http://localhost$(LISTEN_ADDR)/v1} scripts/quickstart.sh + +fmt-check: + @test -z "$$(gofmt -l cmd internal migrations)" || (gofmt -l cmd internal migrations; exit 1) + +lint: fmt-check ## gofmt, go vet, golangci-lint (if installed) + go vet ./... + @if command -v golangci-lint >/dev/null; then golangci-lint run; else echo "golangci-lint not installed; CI runs it"; fi + +check: lint gen-check test-integration build tf-fmt ## Everything CI runs + +build: ## Lambda artifacts for every function into dist/ + @mkdir -p dist + @for f in $(FUNCTIONS); do \ + echo "build $$f"; \ + $(GOFLAGS_LAMBDA) go build -trimpath -tags lambda.norpc -ldflags="-s -w" -o dist/$$f/bootstrap ./cmd/$$f || exit 1; \ + (cd dist/$$f && rm -f ../$$f.zip && zip -q -X ../$$f.zip bootstrap); \ + done + @ls -la dist/*.zip + +tf-fmt: ## terraform fmt -check + terraform fmt -check -recursive infra/terraform + +tf-validate: ## terraform validate every root + @for d in infra/terraform/bootstrap infra/terraform/prod; do \ + (cd $$d && terraform init -backend=false -input=false >/dev/null && terraform validate) || exit 1; done + +tf-plan: ## terraform plan for production (your own read-only credentials) + cd infra/terraform/prod && terraform init -input=false && terraform plan + +job: ## Run one scheduled job locally: make job name=entitlement-sweep + go run ./cmd/jobs -job $(name) + +jobs: ## List scheduled jobs + go run ./cmd/jobs -list + +stripe-listen: ## Forward Stripe test-mode webhooks to the devserver + stripe listen --forward-to localhost$(LISTEN_ADDR)/internal/stripe/webhook + +stripe-catalog: ## Create the Stripe Products/Prices by lookup key (idempotent; test mode) + go run ./cmd/stripe-catalog diff --git a/cmd/admin/main.go b/cmd/admin/main.go new file mode 100644 index 0000000..1f4f200 --- /dev/null +++ b/cmd/admin/main.go @@ -0,0 +1,47 @@ +// Command admin is the one-time, non-API admin tool (NFR → Authentication +// → Bootstrapping): it creates the first superadmin directly against the +// database, which the public API can't do by design. +// +// go run ./cmd/admin bootstrap -email you@example.com # password from ADMIN_PASSWORD or stdin +package main + +import ( + "bufio" + "context" + "flag" + "fmt" + "log" + "os" + "strings" + + "github.com/Adron/substratalapps.com/internal/platform/wire" +) + +func main() { + if len(os.Args) < 2 || os.Args[1] != "bootstrap" { + fmt.Fprintln(os.Stderr, "usage: admin bootstrap -email
") + os.Exit(2) + } + fs := flag.NewFlagSet("bootstrap", flag.ExitOnError) + email := fs.String("email", "", "the superadmin's email") + _ = fs.Parse(os.Args[2:]) + if *email == "" { + log.Fatal("admin: -email is required") + } + password := os.Getenv("ADMIN_PASSWORD") + if password == "" { + fmt.Fprint(os.Stderr, "password (12+ characters): ") + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + password = strings.TrimSpace(line) + } + ctx := context.Background() + base, err := wire.NewBase(ctx) + if err != nil { + log.Fatal(err) + } + id, err := base.Server(nil).BootstrapSuperadmin(ctx, *email, password) + if err != nil { + log.Fatal(err) + } + fmt.Println("superadmin", id, *email) +} diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..9e048d1 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,24 @@ +// Command api is the REST API Lambda: every /v1 route plus the +// /.well-known endpoints, behind API Gateway (HTTP API). +package main + +import ( + "context" + "log" + + "github.com/Adron/substratalapps.com/internal/platform/lambdahttp" + "github.com/Adron/substratalapps.com/internal/platform/wire" +) + +func main() { + ctx := context.Background() + base, err := wire.NewBase(ctx) + if err != nil { + log.Fatal(err) + } + notifier, err := base.Notifier(ctx) + if err != nil { + log.Fatal(err) + } + lambdahttp.Start(base.Server(notifier).Handler()) +} diff --git a/cmd/devserver/main.go b/cmd/devserver/main.go new file mode 100644 index 0000000..bff0f55 --- /dev/null +++ b/cmd/devserver/main.go @@ -0,0 +1,71 @@ +// Command devserver runs the whole API on one port for local development +// (README → Local development): every /v1 route, /.well-known, /mcp, and +// /internal/stripe/webhook, with the webhook worker and the 5-minute sweep +// running in-process instead of SQS and EventBridge. +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/Adron/substratalapps.com/internal/billing" + "github.com/Adron/substratalapps.com/internal/jobs" + "github.com/Adron/substratalapps.com/internal/mcp" + "github.com/Adron/substratalapps.com/internal/platform/wire" + "github.com/Adron/substratalapps.com/internal/webhooks" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + base, err := wire.NewBase(ctx) + if err != nil { + log.Fatal(err) + } + notifier := webhooks.NewLocal(ctx, base.Deliverer(), 2*time.Second) + api := base.Server(notifier) + apiHandler := api.Handler() + + mux := http.NewServeMux() + mux.Handle("/", apiHandler) + mux.Handle("/mcp", &mcp.Server{API: mcp.InProcess(apiHandler), BaseURL: "http://devserver.internal", + Key: []byte(base.Config.CursorSecret)}) + if base.Config.StripeSecretKey != "" && base.Config.StripeWebhookSecret != "" { + stripe := billing.New(base.Config.StripeSecretKey, base.Config.StripePrices, base.Log) + mux.Handle("/internal/stripe/webhook", &billing.Webhook{Secret: base.Config.StripeWebhookSecret, + Stripe: stripe, Core: api, Log: base.Log}) + } + + // The 5-minute entitlement sweep, in-process. + runner := jobs.New(base) + go func() { + t := time.NewTicker(5 * time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + _ = runner.Run(ctx, "entitlement-sweep") + } + } + }() + + srv := &http.Server{Addr: base.Config.ListenAddr, Handler: mux, ReadHeaderTimeout: 10 * time.Second} + go func() { + <-ctx.Done() + shut, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shut) + }() + base.Log.Info("devserver listening", "addr", base.Config.ListenAddr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } +} diff --git a/cmd/jobs/main.go b/cmd/jobs/main.go new file mode 100644 index 0000000..17cf9a2 --- /dev/null +++ b/cmd/jobs/main.go @@ -0,0 +1,47 @@ +// Command jobs runs the scheduled jobs. As a Lambda, EventBridge Scheduler +// invokes it with {"job": ""}. With -job it runs one job locally and +// exits (`make job name=entitlement-sweep`). +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + + "github.com/aws/aws-lambda-go/lambda" + + "github.com/Adron/substratalapps.com/internal/jobs" + "github.com/Adron/substratalapps.com/internal/platform/wire" +) + +type event struct { + Job string `json:"job"` +} + +func main() { + name := flag.String("job", "", "run one job locally and exit") + list := flag.Bool("list", false, "list the jobs") + flag.Parse() + ctx := context.Background() + base, err := wire.NewBase(ctx) + if err != nil { + log.Fatal(err) + } + r := jobs.New(base) + switch { + case *list: + for _, n := range r.Names() { + fmt.Println(n) + } + case *name != "": + if err := r.Run(ctx, *name); err != nil { + log.Fatal(err) + } + case os.Getenv("AWS_LAMBDA_FUNCTION_NAME") != "": + lambda.Start(func(ctx context.Context, ev event) error { return r.Run(ctx, ev.Job) }) + default: + log.Fatal("jobs: pass -job or -list") + } +} diff --git a/cmd/mcp/main.go b/cmd/mcp/main.go new file mode 100644 index 0000000..4d08a07 --- /dev/null +++ b/cmd/mcp/main.go @@ -0,0 +1,28 @@ +// Command mcp is the MCP server Lambda at /mcp. It holds no data-plane +// permissions at all: every tool call is an HTTPS call to the API's own +// /v1 routes on the same API Gateway, with the caller's credential +// forwarded unchanged (DEPLOYMENT.md → MCP server). +package main + +import ( + "log" + "net/http" + "os" + "time" + + "github.com/Adron/substratalapps.com/internal/mcp" + "github.com/Adron/substratalapps.com/internal/platform/lambdahttp" +) + +func main() { + base := os.Getenv("PUBLIC_BASE_URL") + key := os.Getenv("MCP_SESSION_KEY") + if base == "" || key == "" { + log.Fatal("mcp: PUBLIC_BASE_URL and MCP_SESSION_KEY are required") + } + lambdahttp.Start(&mcp.Server{ + API: &http.Client{Timeout: 28 * time.Second}, + BaseURL: base, + Key: []byte(key), + }) +} diff --git a/cmd/mcpgen/main.go b/cmd/mcpgen/main.go new file mode 100644 index 0000000..790505d --- /dev/null +++ b/cmd/mcpgen/main.go @@ -0,0 +1,265 @@ +// Command mcpgen generates the MCP tool surface from docs/openapi.yaml +// (MCP Server → Tool surface is generated, not hand-authored): one tool +// per operationId, named substratal_, +// with its parameters and request body as the input schema and its +// summary/description as the tool description. It also captures the two +// MCP Resources (llms.txt and the Glossary). +// +// Run by `make gen`; CI fails if internal/mcp/tools.json differs from +// what this produces, so the tool list can't drift from the spec. +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// Tool is one generated MCP tool plus what the server needs to route it. +type Tool struct { + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description"` + InputSchema map[string]any `json:"inputSchema"` + Annotations map[string]any `json:"annotations"` + + OperationID string `json:"x_operation_id"` + Method string `json:"x_method"` + Path string `json:"x_path"` + PathParams []string `json:"x_path_params"` + QueryParams []string `json:"x_query_params"` + HasBody bool `json:"x_has_body"` + NeedsIdemKey bool `json:"x_needs_idempotency_key"` + AcceptsIfMatc bool `json:"x_accepts_if_match"` +} + +// Resource is one static MCP resource. +type Resource struct { + URI string `json:"uri"` + Name string `json:"name"` + Description string `json:"description"` + MimeType string `json:"mimeType"` + Text string `json:"text"` +} + +type output struct { + Generated string `json:"_generated"` + Tools []Tool `json:"tools"` + Resources []Resource `json:"resources"` +} + +var doc map[string]any + +func main() { + raw, err := os.ReadFile("docs/openapi.yaml") + if err != nil { + log.Fatal(err) + } + if err := yaml.Unmarshal(raw, &doc); err != nil { + log.Fatal(err) + } + paths := doc["paths"].(map[string]any) + var tools []Tool + for _, p := range sortedKeys(paths) { + ops := paths[p].(map[string]any) + shared := asList(ops["parameters"]) + for _, method := range []string{"get", "post", "patch", "put", "delete"} { + op, ok := ops[method].(map[string]any) + if !ok { + continue + } + tools = append(tools, tool(p, strings.ToUpper(method), op, shared)) + } + } + sort.Slice(tools, func(i, j int) bool { return tools[i].Name < tools[j].Name }) + out := output{Generated: "by cmd/mcpgen from docs/openapi.yaml; do not edit", Tools: tools, Resources: resources()} + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + log.Fatal(err) + } + if err := os.WriteFile("internal/mcp/tools.json", append(b, '\n'), 0o644); err != nil { + log.Fatal(err) + } + fmt.Printf("generated %d tools\n", len(tools)) +} + +func tool(path, method string, op map[string]any, shared []any) Tool { + id := op["operationId"].(string) + t := Tool{ + Name: "substratal_" + strings.ReplaceAll(id, ".", "_"), + Title: str(op["summary"]), + OperationID: id, Method: method, Path: "/v1" + path, + } + desc := strings.TrimSpace(str(op["summary"])) + if d := strings.TrimSpace(str(op["description"])); d != "" { + desc += "\n\n" + d + } + desc += fmt.Sprintf("\n\nREST: %s %s", method, t.Path) + t.Description = desc + + props := map[string]any{} + var required []string + for _, pr := range append(shared, asList(op["parameters"])...) { + param := resolve(pr).(map[string]any) + name, in := str(param["name"]), str(param["in"]) + switch in { + case "path": + t.PathParams = append(t.PathParams, name) + required = append(required, name) + case "query": + t.QueryParams = append(t.QueryParams, name) + case "header": + if name == "Idempotency-Key" { + t.NeedsIdemKey = param["required"] == true + continue // derived from the arguments, never asked for + } + if name == "If-Match" { + t.AcceptsIfMatc = true + props["if_match"] = map[string]any{"type": "string", "description": "ETag from a prior read; a mismatch returns 409 version_conflict."} + continue + } + continue + } + sch := inline(param["schema"], 0) + if s, ok := sch.(map[string]any); ok { + if d := str(param["description"]); d != "" { + s["description"] = d + } + } + props[name] = sch + if in == "query" && param["required"] == true { + required = append(required, name) + } + } + if rb, ok := op["requestBody"].(map[string]any); ok { + if content, ok := rb["content"].(map[string]any); ok { + if js, ok := content["application/json"].(map[string]any); ok { + t.HasBody = true + props["body"] = inline(js["schema"], 0) + if rb["required"] == true { + required = append(required, "body") + } + } + } + } + schema := map[string]any{"type": "object", "properties": props, "additionalProperties": false} + if len(required) > 0 { + schema["required"] = required + } + t.InputSchema = schema + + _, destructive := op["x-substratal-destructive"] + readOnly := method == "GET" + idempotent := method == "GET" || method == "PATCH" || method == "DELETE" || (method == "POST" && t.NeedsIdemKey) + t.Annotations = map[string]any{ + "title": t.Title, "readOnlyHint": readOnly, "destructiveHint": destructive, "idempotentHint": idempotent, + "openWorldHint": false, + } + return t +} + +var refRe = regexp.MustCompile(`^#/(.+)$`) + +// resolve follows a local $ref one level. +func resolve(v any) any { + m, ok := v.(map[string]any) + if !ok { + return v + } + ref, ok := m["$ref"].(string) + if !ok { + return v + } + parts := strings.Split(refRe.FindStringSubmatch(ref)[1], "/") + var cur any = doc + for _, p := range parts { + cur = cur.(map[string]any)[p] + } + return cur +} + +// inline returns a copy of a schema with every local $ref expanded, so +// tool input schemas are self-contained. +func inline(v any, depth int) any { + if depth > 12 { + return map[string]any{} + } + switch x := v.(type) { + case map[string]any: + if _, ok := x["$ref"]; ok { + return inline(resolve(x), depth+1) + } + out := map[string]any{} + for k, val := range x { + if strings.HasPrefix(k, "x-") || k == "example" || k == "readOnly" { + continue + } + out[k] = inline(val, depth+1) + } + return out + case []any: + out := make([]any, len(x)) + for i, val := range x { + out[i] = inline(val, depth+1) + } + return out + default: + return v + } +} + +func resources() []Resource { + base := "https://adron.github.io/substratalapps.com" + llms, err := os.ReadFile("docs/llms.txt") + if err != nil { + log.Fatal(err) + } + text := string(llms) + if i := strings.Index(text, "---\n"); i == 0 { + if j := strings.Index(text[4:], "---\n"); j >= 0 { + text = text[4+j+4:] + } + } + text = strings.ReplaceAll(text, "{{ site.url }}{{ site.baseurl }}", base) + glossary, err := os.ReadFile("docs/glossary.md") + if err != nil { + log.Fatal(err) + } + g := string(glossary) + if strings.HasPrefix(g, "---\n") { + if j := strings.Index(g[4:], "---\n"); j >= 0 { + g = g[4+j+4:] + } + } + return []Resource{ + {URI: base + "/llms.txt", Name: "llms.txt", Description: "Machine-oriented index of every page of the Substratal Apps API specification.", + MimeType: "text/markdown", Text: strings.TrimSpace(text) + "\n"}, + {URI: base + "/glossary/", Name: "Glossary", Description: "Precise definitions for every term the API uses.", + MimeType: "text/markdown", Text: strings.TrimSpace(g) + "\n"}, + } +} + +func str(v any) string { + s, _ := v.(string) + return s +} + +func asList(v any) []any { + l, _ := v.([]any) + return l +} + +func sortedKeys(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..1e75f0d --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,69 @@ +// Command migrate applies pending migrations from migrations/. +// +// go run ./cmd/migrate # local Postgres (DATABASE_URL) +// DATABASE_BACKEND=dataapi go run ./cmd/migrate # Aurora via the Data API (deploy.yml) +// go run ./cmd/migrate -new add_widgets # create the next numbered file +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "github.com/Adron/substratalapps.com/internal/db/migrate" + "github.com/Adron/substratalapps.com/internal/platform/config" + "github.com/Adron/substratalapps.com/internal/platform/wire" + "github.com/Adron/substratalapps.com/migrations" +) + +func main() { + newName := flag.String("new", "", "create the next numbered migration file with this name") + flag.Parse() + + all, err := migrate.Load(migrations.FS) + if err != nil { + log.Fatal(err) + } + if *newName != "" { + next := 1 + if len(all) > 0 { + if _, err := fmt.Sscanf(all[len(all)-1].Version, "%d", &next); err != nil { + log.Fatal(err) + } + next++ + } + name := filepath.Join("migrations", fmt.Sprintf("%04d_%s.sql", next, *newName)) + body := fmt.Sprintf("-- %04d_%s\n--\n-- Expand-only unless this is a planned contract step; see README →\n-- Migrations: expand, then contract.\n", next, *newName) + if err := os.WriteFile(name, []byte(body), 0o644); err != nil { + log.Fatal(err) + } + fmt.Println("created", name) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + cfg, err := config.Load() + if err != nil { + log.Fatal(err) + } + d, err := wire.Database(ctx, cfg) + if err != nil { + log.Fatal(err) + } + defer d.Close() + done, err := migrate.Up(ctx, d, all) + for _, n := range done { + fmt.Println("applied", n) + } + if err != nil { + log.Fatal(err) + } + if len(done) == 0 { + fmt.Println("up to date") + } +} diff --git a/cmd/stripe-catalog/main.go b/cmd/stripe-catalog/main.go new file mode 100644 index 0000000..5faa6a0 --- /dev/null +++ b/cmd/stripe-catalog/main.go @@ -0,0 +1,91 @@ +// Command stripe-catalog creates the Stripe Products and Prices in +// Pricing → Stripe catalog, once per Stripe mode. It's idempotent: a Price +// whose lookup_key already exists is left alone, and Products use fixed +// ids, so it's safe to re-run. Point it at test mode first. +// +// STRIPE_SECRET_KEY=sk_test_... go run ./cmd/stripe-catalog +package main + +import ( + "context" + "fmt" + "log" + "os" + "strings" + + "github.com/stripe/stripe-go/v87" +) + +type price struct { + lookup string + product string + recur bool + amount int64 + tiers []*stripe.PriceCreateTierParams +} + +func main() { + key := os.Getenv("STRIPE_SECRET_KEY") + if key == "" { + log.Fatal("stripe-catalog: STRIPE_SECRET_KEY is required") + } + if strings.HasPrefix(key, "sk_live_") && os.Getenv("CONFIRM_LIVE") != "yes" { + log.Fatal("stripe-catalog: refusing live mode without CONFIRM_LIVE=yes") + } + sc := stripe.NewClient(key) + ctx := context.Background() + products := map[string]map[string]string{ + "substratal_team": {"name": "Substratal Team", "substratal_plan": "team"}, + "substratal_addon_isolated": {"name": "Isolated tenancy add-on", "substratal_addon": "isolated_tenancy"}, + "substratal_addon_dedicated": {"name": "Dedicated-region tenancy add-on", "substratal_addon": "dedicated_region_tenancy"}, + "substratal_enterprise": {"name": "Substratal Enterprise", "substratal_plan": "enterprise"}, + } + for id, meta := range products { + name := meta["name"] + delete(meta, "name") + p := &stripe.ProductCreateParams{ID: stripe.String(id), Name: stripe.String(name), Metadata: meta} + if _, err := sc.V1Products.Create(ctx, p); err != nil && !strings.Contains(err.Error(), "already exists") { + log.Fatalf("product %s: %v", id, err) + } + fmt.Println("product", id) + } + prices := []price{ + {lookup: "team_base_monthly_usd", product: "substratal_team", recur: true, amount: 4900}, + {lookup: "team_seats_monthly_usd", product: "substratal_team", recur: true, tiers: []*stripe.PriceCreateTierParams{ + {UpTo: stripe.Int64(25), UnitAmount: stripe.Int64(0)}, + {UpToInf: stripe.Bool(true), UnitAmount: stripe.Int64(600)}, + }}, + {lookup: "addon_isolated_monthly_usd", product: "substratal_addon_isolated", recur: true, amount: 75000}, + {lookup: "addon_dedicated_region_monthly_usd", product: "substratal_addon_dedicated", recur: true, amount: 150000}, + } + existing := map[string]bool{} + lp := &stripe.PriceListParams{} + for _, pr := range prices { + lp.LookupKeys = append(lp.LookupKeys, stripe.String(pr.lookup)) + } + for p, err := range sc.V1Prices.List(ctx, lp).All(ctx) { + if err != nil { + log.Fatal(err) + } + existing[p.LookupKey] = true + } + for _, pr := range prices { + if existing[pr.lookup] { + fmt.Println("price", pr.lookup, "(exists)") + continue + } + p := &stripe.PriceCreateParams{ + Currency: stripe.String("usd"), LookupKey: stripe.String(pr.lookup), Product: stripe.String(pr.product), + Recurring: &stripe.PriceCreateRecurringParams{Interval: stripe.String("month"), UsageType: stripe.String("licensed")}, + } + if pr.tiers != nil { + p.BillingScheme, p.TiersMode, p.Tiers = stripe.String("tiered"), stripe.String("graduated"), pr.tiers + } else { + p.UnitAmount = stripe.Int64(pr.amount) + } + if _, err := sc.V1Prices.Create(ctx, p); err != nil { + log.Fatalf("price %s: %v", pr.lookup, err) + } + fmt.Println("price", pr.lookup, "(created)") + } +} diff --git a/cmd/stripe-webhook/main.go b/cmd/stripe-webhook/main.go new file mode 100644 index 0000000..68c6571 --- /dev/null +++ b/cmd/stripe-webhook/main.go @@ -0,0 +1,27 @@ +// Command stripe-webhook is the Lambda at /internal/stripe/webhook: the one +// route whose caller isn't a Bearer holder at all, verified instead by +// Stripe's own signature (DEPLOYMENT.md → Stripe Billing). +package main + +import ( + "context" + "log" + + "github.com/Adron/substratalapps.com/internal/billing" + "github.com/Adron/substratalapps.com/internal/platform/lambdahttp" + "github.com/Adron/substratalapps.com/internal/platform/wire" +) + +func main() { + ctx := context.Background() + base, err := wire.NewBase(ctx) + if err != nil { + log.Fatal(err) + } + if base.Config.StripeSecretKey == "" || base.Config.StripeWebhookSecret == "" { + log.Fatal("stripe-webhook: stripe_secret_key and stripe_webhook_secret are required") + } + stripe := billing.New(base.Config.StripeSecretKey, base.Config.StripePrices, base.Log) + lambdahttp.Start(&billing.Webhook{Secret: base.Config.StripeWebhookSecret, Stripe: stripe, + Core: base.Server(nil), Log: base.Log}) +} diff --git a/cmd/webhook-worker/main.go b/cmd/webhook-worker/main.go new file mode 100644 index 0000000..dc549f8 --- /dev/null +++ b/cmd/webhook-worker/main.go @@ -0,0 +1,26 @@ +// Command webhook-worker is the SQS-triggered Lambda that delivers +// outbound webhooks. A message only nudges it; it always drains the +// outbox and every due retry, so a lost or duplicate message is harmless. +package main + +import ( + "context" + "log" + + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" + + "github.com/Adron/substratalapps.com/internal/platform/wire" +) + +func main() { + ctx := context.Background() + base, err := wire.NewBase(ctx) + if err != nil { + log.Fatal(err) + } + d := base.Deliverer() + lambda.Start(func(ctx context.Context, ev events.SQSEvent) error { + return d.Run(ctx) + }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c3b55d3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +# Local services for the API (README → Local development). Host ports are +# deliberately non-standard so they don't collide with another project's +# Postgres or mail catcher; override any of them in .env. +name: substratal + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - "${POSTGRES_PORT:-55432}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + # Creates the non-superuser `substratal` role the API connects as. + # A superuser bypasses Row-Level Security, which would hide RLS bugs. + - ./docker/postgres-init.sql:/docker-entrypoint-initdb.d/10-substratal.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 2s + timeout: 3s + retries: 30 + + localstack: + image: localstack/localstack:3 + environment: + SERVICES: sqs,kms,secretsmanager,s3 + ports: + - "${LOCALSTACK_PORT:-4566}:4566" + + mailpit: + image: axllent/mailpit:latest + ports: + - "${MAILPIT_SMTP_PORT:-51025}:1025" + - "${MAILPIT_UI_PORT:-58025}:8025" + +volumes: + pgdata: diff --git a/docker/postgres-init.sql b/docker/postgres-init.sql new file mode 100644 index 0000000..30b7c83 --- /dev/null +++ b/docker/postgres-init.sql @@ -0,0 +1,7 @@ +-- Runs once, when the Postgres volume is first created. +-- The API, migrations, and tests all connect as `substratal`: a normal +-- (non-superuser) role that owns its database, so FORCE ROW LEVEL SECURITY +-- applies to it exactly as it does to the Aurora application user. +create role substratal login password 'substratal' createdb; +create database substratal owner substratal; +create database substratal_test owner substratal; diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9879d01..a2c68ac 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1558,10 +1558,16 @@ paths: operationId: organizations.listEntitlements tags: [Organizations] summary: List org-wide (seat) grants + description: Same filters as GET /entitlements, scoped to this Organization. x-substratal-phase: phase2 parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" + - { name: application_id, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: "#/components/schemas/EntitlementStatus" } } + - { name: order_id, in: query, schema: { type: string } } + - { name: ends_before, in: query, schema: { type: string, format: date-time } } + - { name: include_inactive, in: query, schema: { type: boolean } } responses: "200": description: A page of Entitlements @@ -1616,6 +1622,8 @@ paths: - { name: tier, in: query, schema: { $ref: "#/components/schemas/TenantTier" } } - { name: status, in: query, schema: { $ref: "#/components/schemas/TenantStatus" } } - { name: restricted, in: query, schema: { type: boolean } } + - { name: owner_user_id, in: query, schema: { type: string } } + - { name: owner_organization_id, in: query, schema: { type: string } } responses: "200": description: A page of Tenants @@ -2550,8 +2558,8 @@ components: id: { type: string } slug: { type: string } name: { type: string } - description: { type: string } - icon_url: { type: string } + description: { type: [string, "null"] } + icon_url: { type: [string, "null"] } visibility: { $ref: "#/components/schemas/ApplicationVisibility" } review_status: { $ref: "#/components/schemas/ApplicationReviewStatus" } @@ -2565,10 +2573,10 @@ components: ApplicationWrite: type: object properties: - slug: { type: string, pattern: "^(?!platform$)[a-z][a-z0-9-]{1,63}$", description: "Create only; immutable. 'platform' is reserved. The Application's id is app_." } + slug: { type: string, pattern: "^[a-z][a-z0-9-]{1,63}$", description: "Create only; immutable. 'platform' is reserved (rejected with validation_failed, code unknown_value). The Application's id is app_." } name: { type: string, minLength: 1, maxLength: 100 } - description: { type: string, maxLength: 2000 } - icon_url: { type: string, format: uri } + description: { type: [string, "null"], maxLength: 2000 } + icon_url: { type: [string, "null"], format: uri } launch_url: { type: string, format: uri } redirect_uris: { type: array, maxItems: 10, items: { type: string } } support_url: { type: [string, "null"], format: uri } @@ -2673,7 +2681,7 @@ components: properties: name: { type: string, pattern: "^[a-z][a-z0-9_]{1,40}$" } scope: { type: string, description: "'platform' or an application_id" } - description: { type: string, maxLength: 2000 } + description: { type: [string, "null"], maxLength: 2000 } permissions: { type: array, items: { type: string } } Role: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e0a6bac --- /dev/null +++ b/go.mod @@ -0,0 +1,74 @@ +module github.com/Adron/substratalapps.com + +go 1.26.0 + +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen + +require ( + github.com/aws/aws-lambda-go v1.55.1 + github.com/aws/aws-sdk-go-v2 v1.47.1 + github.com/aws/aws-sdk-go-v2/config v1.33.6 + github.com/aws/aws-sdk-go-v2/service/kms v1.61.1 + github.com/aws/aws-sdk-go-v2/service/rdsdata v1.40.1 + github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.50.1 + github.com/aws/aws-sdk-go-v2/service/sesv2 v1.77.0 + github.com/aws/aws-sdk-go-v2/service/sqs v1.52.1 + github.com/jackc/pgx/v5 v5.11.0 + github.com/oapi-codegen/runtime v1.7.0 + github.com/oklog/ulid/v2 v2.1.2 + github.com/pb33f/libopenapi v0.41.1 + github.com/pb33f/libopenapi-validator v0.15.0 + github.com/stripe/stripe-go/v87 v87.0.0 + golang.org/x/crypto v0.57.0 + golang.org/x/text v0.42.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.20.6 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect + github.com/aws/smithy-go v1.28.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad // indirect + github.com/buger/jsonparser v1.1.2 // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/getkin/kin-openapi v0.142.0 // indirect + github.com/go-openapi/jsonpointer v0.23.2 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect + github.com/pb33f/go-yaml v0.1.0 // indirect + github.com/pb33f/jsonpath v0.8.4 // indirect + github.com/pb33f/ordered-map/v2 v2.3.2 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/speakeasy-api/jsonpath v0.6.3 // indirect + github.com/speakeasy-api/openapi v1.24.0 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/tools v0.49.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..45d4504 --- /dev/null +++ b/go.sum @@ -0,0 +1,295 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/aws/aws-lambda-go v1.55.1 h1:We2cCp4BwqqH/JW+bEEo1FhgG71rslvjfi4y7KmlrR0= +github.com/aws/aws-lambda-go v1.55.1/go.mod h1:V+NzkHNR6vBC8C1PDloqSLE+7jYWFiPvJJFiCiTm8nE= +github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw= +github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8= +github.com/aws/aws-sdk-go-v2/config v1.33.6 h1:MBjkSTLczek/UgiK+EYPIoRTqE7gP8vtW3OFbFo7Nug= +github.com/aws/aws-sdk-go-v2/config v1.33.6/go.mod h1:grRAFzdAZJrwcbasJRg2MPvIrVjtlfXllHssN6+E1JE= +github.com/aws/aws-sdk-go-v2/credentials v1.20.6 h1:NpAFXCU7NzXNkdGK3zQTtsRJ+3v9tZQV0xcdRw8uBdw= +github.com/aws/aws-sdk-go-v2/credentials v1.20.6/go.mod h1:mcZCoiPnyMvP8VMNbygNX5lLqSlkYJIMPODylQMurOk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 h1:8gALAAmacnIXh+z6VkdDanv4/IkG5APdg4DZLDTmLog= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1/go.mod h1:Z7IJhJU+poOdJjUR2wpyY21ossQ1XS/R3Lk9Msq5kM4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 h1:7Wo47d/xn/7KttCSBd8EGYeZ7ULRFRkUHr6vkZPBzVQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4/go.mod h1:tDB2IVC1xC3vX8o+6uRlzhTxP3g1b77CZXFX/oD2FnQ= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 h1:/TYsZXdA8UTa+WCtCYSAJIr1vwl0+eho6TUgJGwFFO8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5/go.mod h1:qPqp1Uwd/BqdhPufv6oem9j5J7HNsgc2V22dUiDPn+s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 h1:29SvnfGhXjTl8ONxFwbj2rs6lbhiFXD2CgFQmbT/bXY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4/go.mod h1:wm04I5DMuNVvZHFe/dHnUxincvNbbK7AiNBbYsQivek= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 h1:pPiWfgeNxqluKEph7hvU88kuGKBPOWzO+Dk9t2zqqNs= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4/go.mod h1:YlwGoIUDG/3kBQbdNOVs/xKZ9J01G8e/6D1mRBj9uTk= +github.com/aws/aws-sdk-go-v2/service/kms v1.61.1 h1:BNBCE5IGMCehEPpSbPqhdyV4ZS9Y1Yr9NuvR9itr7aE= +github.com/aws/aws-sdk-go-v2/service/kms v1.61.1/go.mod h1:XBCtQL8tXGOCYe8ExoWRURhDQ5QnfyWbP9px5DNsuog= +github.com/aws/aws-sdk-go-v2/service/rdsdata v1.40.1 h1:D9VXQ2m/LmE/2TOt0RNnbZ7cqcYK11hzoLaLJimE1UI= +github.com/aws/aws-sdk-go-v2/service/rdsdata v1.40.1/go.mod h1:7/+FiHeFdqEzblJCLzVt1KCnykWPCR9X219I49MkBAw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 h1:VMAdYqr4Jn/8ATs9BHC5riwrs0d6m1Z2ohFriSwZwm0= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0/go.mod h1:9APRWGLFITKD+xzWSIyT9V7QV4bNlEuIieWlzXgGFlI= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.50.1 h1:xYoGDAZtoSXI5wOfjv1jzG1AUOdXZthz4YL9DFvunrQ= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.50.1/go.mod h1:dgXxccOMNsXm/eOkrQbBfxm4a6H8IiRphA7z69RG8hM= +github.com/aws/aws-sdk-go-v2/service/sesv2 v1.77.0 h1:hl/wkCN+oqbGVuZh6CJ4nbzJUq91KXaOi30ub+n8kjo= +github.com/aws/aws-sdk-go-v2/service/sesv2 v1.77.0/go.mod h1:BD8BTTPSiyOP++OliGXivxk+nHvQ+2XL16N1ziph+Fk= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 h1:DzCCWLzcIRQ77F3DEUljud7bEjTgFOIKXP52NmVRyhU= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.1/go.mod h1:xpo/geVldu8payT375WekctUzopG/hBU7miiqItMUlw= +github.com/aws/aws-sdk-go-v2/service/sqs v1.52.1 h1:jBQM8NL0q3h0ZpHqo4TxOD9Ope96SlEF1Y6VLsF20nQ= +github.com/aws/aws-sdk-go-v2/service/sqs v1.52.1/go.mod h1:+TDqZ1h8CLkW9ewfQkSPWHYRjm7/wDThKeDlR46qyvE= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 h1:Umtl/0YZhng4xndfW3lKJrYYP7NLEjI6bGXVomwLcs0= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.1/go.mod h1:rRD/dnm7q0HYE/I5TMaPgkWyyUGLcwuxHLABsLnQ3e0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 h1:orIWdNiLgzrhu/11RcPPKO/SBzUUymbUQuZbSPImghg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZjfhz0zXtjs6gdE8s= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad h1:3swAvbzgfaI6nKuDDU7BiKfZRdF+h2ZwKgMHd8Ha4t8= +github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad/go.mod h1:9+nBLYNWkvPcq9ep0owWUsPTLgL9ZXTsZWcCSVGGLJ0= +github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow= +github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w= +github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= +github.com/go-openapi/jsonpointer v0.23.2 h1:DK7R/3zAt4xTytxNkw7jARGPFI7rkaSsii58n8X45x0= +github.com/go-openapi/jsonpointer v0.23.2/go.mod h1:noUOckXtq7b4bVkqw0sbHKieq9uEZRN7p6EF/dalc4w= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.0/go.mod h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo= +github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k= +github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= +github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= +github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/pb33f/go-yaml v0.1.0 h1:zZ6Gg+ATbumXlXZNLMHIe7+zTkVqe20lx+TSYRrXEuY= +github.com/pb33f/go-yaml v0.1.0/go.mod h1:QvWdkHP5VjFYAeHMQUkaPQqREaQnBNOXmWzAjNrnSdo= +github.com/pb33f/jsonpath v0.8.4 h1:Yx7fxsKl8scgERL22WhKDs2c6gYNNUonPSWEG8Y194s= +github.com/pb33f/jsonpath v0.8.4/go.mod h1:eO6mgdhw5RgzCxm/bXTi75VUs0/cCYJzdGjMCCUEI6M= +github.com/pb33f/libopenapi v0.41.1 h1:k2TzRNoFWdwtgZK8LpmqIHIPxD/6nExHkmB7QZXBets= +github.com/pb33f/libopenapi v0.41.1/go.mod h1:okMH18Y4oyUXUSCr84u+hHe7t/gWDwoHv/f3CEls4SQ= +github.com/pb33f/libopenapi-validator v0.15.0 h1:5wl7/tpyewqzZ7Y5/M9CZjojlaaK2DYKPZYGIvPq7SI= +github.com/pb33f/libopenapi-validator v0.15.0/go.mod h1:P52RfZsY/+oWjRL52mpQ8Icwual6rtkJt1qOWdAe4eM= +github.com/pb33f/ordered-map/v2 v2.3.2 h1:wDyaZ2Pv9QLh64X4utCeD5Zoi9nIv2aW3lwv172O5PQ= +github.com/pb33f/ordered-map/v2 v2.3.2/go.mod h1:1OhFrXu3OYw3kM+FXF+Ug3ugmmZ9LE8z0JfQMLvtV0w= +github.com/pb33f/testify v0.1.1 h1:mnHe7uxKt8dyNYEGUspow72VjD264DB5rOJq4bz5tJw= +github.com/pb33f/testify v0.1.1/go.mod h1:keghMqOLECF1ENzESnfM+cwPcKN5uhTOpvbtjgL6aZg= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= +github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= +github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04= +github.com/speakeasy-api/openapi v1.24.0/go.mod h1:g3+dIMe0AYgbbGvnlQZqesmjAVWSm9BmsjLevnefQrg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stripe/stripe-go/v87 v87.0.0 h1:dDztIAKst1hNfNpv4zo3zhVnoLiXJx0fH3Kl+KlGP6Y= +github.com/stripe/stripe-go/v87 v87.0.0/go.mod h1:UQKKBVL4DY/evB1M1KczpoCaJi1oFnZ/gRPKukRXYYA= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/infra/terraform/bootstrap/.terraform.lock.hcl b/infra/terraform/bootstrap/.terraform.lock.hcl new file mode 100644 index 0000000..35ba677 --- /dev/null +++ b/infra/terraform/bootstrap/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.67.0" + constraints = "~> 6.0" + hashes = [ + "h1:OgdIUAQDtJBxlKjoPgChD1w57vl6hm6PyJHiBYgsgQA=", + "zh:111d5686a1f4ccbc888bb5e2229308bcdd9149898c6af97969fcdfb0e7bd2aa0", + "zh:21b3b7693bd9754c039fd546f03ed09e7510c6189aa5ee37176f144cf8dd5f95", + "zh:25e03c7f025ff4851889537605aa560d2e39bd738b33a5204b8037eb164b475f", + "zh:2817a046f1060e25bf04b0eb78f4e251130bb92dc82ec1264ce156dc9bf78e67", + "zh:2d318d674c3ec9dbd97ddb10b12ad4827be4f508c43ba2ae1e0523baa9e367e3", + "zh:2f07ab356718267299e10b6072472ded29d82753883027bf43bcd687ac72feb2", + "zh:32307e67f83bc0dc94d38cfcd23d782166a09e0e975e2a5aa7d7a3e7ca5d3da8", + "zh:4ce94853264097dd7f4542b3cfd18d2b98b06d23321db45d5e384ea275a57f63", + "zh:869656c41cc7c7412f213e488f98167cff61deafcb8cf245d25d056e8dfdc263", + "zh:8fd8c814e9d8edf152552047db23bf5b5d63f22e6b0b5d47b2af851376e99349", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9cbc02ceef9bd469da497a1e7065ff984bdacfbe919ac3cce804a86c13b6e2c6", + "zh:9ea55dda2767acfc1f6337fc7d29b1d4d79d6f8f04871f8ad99a6078d2865994", + "zh:d7149df0819fb57a160489db45d33951765b8f0118daa3b4cd549a0135bc97a1", + "zh:e5819937bb7043d08c9829b32d52d9fb55a5b9a4d8d55d550d47a3d7392db546", + "zh:f93bc38dbc0ad53842303f30eec7a22c525c4d56209b54ec33a8d375cfc4e4fd", + ] +} diff --git a/infra/terraform/bootstrap/main.tf b/infra/terraform/bootstrap/main.tf new file mode 100644 index 0000000..9f0a9ac --- /dev/null +++ b/infra/terraform/bootstrap/main.tf @@ -0,0 +1,293 @@ +# Applied once, by hand, with admin credentials (README → One-time setup). +# It's the only Terraform ever applied from a laptop. In order: +# 1. Cost guardrails, before any other billable resource (DEPLOYMENT.md → +# Cost guardrails: "a hard budget alarm exists before the first +# resource does"). +# 2. The Terraform state bucket and the artifacts bucket. +# 3. The GitHub OIDC provider and the two CI roles: a read-only plan role +# (pull requests into main) and the deploy role (main + production +# environment only). + +terraform { + required_version = ">= 1.10" + required_providers { + aws = { source = "hashicorp/aws", version = "~> 6.0" } + } +} + +provider "aws" { + region = var.region + default_tags { tags = { project = "substratal", managed_by = "terraform", stack = "bootstrap" } } +} + +data "aws_caller_identity" "me" {} + +# ── 1. Cost guardrails ────────────────────────────────────────────────── + +resource "aws_sns_topic" "billing_alerts" { + name = "substratal-billing-alerts" +} + +resource "aws_sns_topic_subscription" "billing_email" { + topic_arn = aws_sns_topic.billing_alerts.arn + protocol = "email" + endpoint = var.billing_email +} + +resource "aws_budgets_budget" "warn" { + name = "substratal-warn" + budget_type = "COST" + limit_amount = tostring(var.warn_budget_usd) + limit_unit = "USD" + time_unit = "MONTHLY" + dynamic "notification" { + for_each = [50, 80, 100] + content { + comparison_operator = "GREATER_THAN" + threshold = notification.value + threshold_type = "PERCENTAGE" + notification_type = "ACTUAL" + subscriber_sns_topic_arns = [aws_sns_topic.billing_alerts.arn] + } + } +} + +# The hard budget is a real circuit breaker: at the threshold, a Budget +# Action attaches a deny-everything-billable policy to the deploy role and +# the Lambda execution roles' boundary group. +resource "aws_budgets_budget" "hard" { + name = "substratal-hard" + budget_type = "COST" + limit_amount = tostring(var.hard_budget_usd) + limit_unit = "USD" + time_unit = "MONTHLY" + notification { + comparison_operator = "GREATER_THAN" + threshold = 100 + threshold_type = "PERCENTAGE" + notification_type = "ACTUAL" + subscriber_sns_topic_arns = [aws_sns_topic.billing_alerts.arn] + } +} + +resource "aws_iam_policy" "circuit_breaker" { + name = "substratal-budget-circuit-breaker" + description = "Attached by the hard budget action: stops new spend on non-essential services." + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Deny" + Action = ["lambda:CreateFunction", "lambda:UpdateFunctionCode", "rds:CreateDBCluster", "rds:ModifyDBCluster", "ec2:RunInstances"] + Resource = "*" + }] + }) +} + +resource "aws_iam_role" "budget_action" { + name = "substratal-budget-action" + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ Effect = "Allow", Principal = { Service = "budgets.amazonaws.com" }, Action = "sts:AssumeRole" }] + }) +} + +resource "aws_iam_role_policy" "budget_action" { + role = aws_iam_role.budget_action.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ Effect = "Allow", Action = ["iam:AttachRolePolicy", "iam:DetachRolePolicy"], Resource = aws_iam_role.deploy.arn }] + }) +} + +resource "aws_budgets_budget_action" "hard_stop" { + budget_name = aws_budgets_budget.hard.name + action_type = "APPLY_IAM_POLICY" + approval_model = "AUTOMATIC" + notification_type = "ACTUAL" + execution_role_arn = aws_iam_role.budget_action.arn + action_threshold { + action_threshold_type = "PERCENTAGE" + action_threshold_value = 100 + } + definition { + iam_action_definition { + policy_arn = aws_iam_policy.circuit_breaker.arn + roles = [aws_iam_role.deploy.name] + } + } + subscriber { + address = var.billing_email + subscription_type = "EMAIL" + } +} + +resource "aws_ce_anomaly_monitor" "services" { + name = "substratal-services" + monitor_type = "DIMENSIONAL" + monitor_dimension = "SERVICE" +} + +resource "aws_ce_anomaly_subscription" "daily" { + name = "substratal-anomalies" + frequency = "DAILY" + monitor_arn_list = [aws_ce_anomaly_monitor.services.arn] + subscriber { + type = "EMAIL" + address = var.billing_email + } + threshold_expression { + dimension { + key = "ANOMALY_TOTAL_IMPACT_ABSOLUTE" + values = ["10"] + match_options = ["GREATER_THAN_OR_EQUAL"] + } + } +} + +# A third, independent tripwire. Billing metrics only exist in us-east-1. +resource "aws_cloudwatch_metric_alarm" "estimated_charges" { + alarm_name = "substratal-estimated-charges" + namespace = "AWS/Billing" + metric_name = "EstimatedCharges" + dimensions = { Currency = "USD" } + statistic = "Maximum" + period = 21600 + evaluation_periods = 1 + threshold = var.warn_budget_usd + comparison_operator = "GreaterThanThreshold" + alarm_actions = [aws_sns_topic.billing_alerts.arn] +} + +# ── 2. State and artifacts ────────────────────────────────────────────── + +resource "aws_s3_bucket" "state" { + bucket = "substratal-tfstate-${data.aws_caller_identity.me.account_id}" +} + +resource "aws_s3_bucket" "artifacts" { + bucket = "substratal-artifacts-${data.aws_caller_identity.me.account_id}" +} + +resource "aws_s3_bucket_versioning" "state" { + bucket = aws_s3_bucket.state.id + versioning_configuration { status = "Enabled" } +} + +resource "aws_s3_bucket_versioning" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + versioning_configuration { status = "Enabled" } +} + +resource "aws_s3_bucket_lifecycle_configuration" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + rule { + id = "expire-old-releases" + status = "Enabled" + filter {} + expiration { days = 180 } + noncurrent_version_expiration { noncurrent_days = 30 } + } +} + +resource "aws_s3_bucket_public_access_block" "state" { + bucket = aws_s3_bucket.state.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_public_access_block" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "state" { + bucket = aws_s3_bucket.state.id + rule { + apply_server_side_encryption_by_default { sse_algorithm = "AES256" } + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + rule { + apply_server_side_encryption_by_default { sse_algorithm = "AES256" } + } +} + +# ── 3. GitHub OIDC and CI roles ───────────────────────────────────────── + +resource "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" + client_id_list = ["sts.amazonaws.com"] +} + +locals { + oidc_sub = "repo:${var.github_repository}" +} + +# Read-only: terraform plan on pull requests into main. +resource "aws_iam_role" "plan" { + name = "substratal-github-plan" + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Federated = aws_iam_openid_connect_provider.github.arn } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" } + StringLike = { "token.actions.githubusercontent.com:sub" = "${local.oidc_sub}:pull_request" } + } + }] + }) +} + +resource "aws_iam_role_policy_attachment" "plan_readonly" { + role = aws_iam_role.plan.name + policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess" +} + +resource "aws_iam_role_policy" "plan_state" { + role = aws_iam_role.plan.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["s3:GetObject", "s3:ListBucket"] + Resource = [aws_s3_bucket.state.arn, "${aws_s3_bucket.state.arn}/*"] + }] + }) +} + +# Deploy: only main, only the production environment. This trust policy is +# the AWS-side enforcement of "merging into main is the only way to deploy". +resource "aws_iam_role" "deploy" { + name = "substratal-github-deploy" + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Federated = aws_iam_openid_connect_provider.github.arn } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" + "token.actions.githubusercontent.com:sub" = "${local.oidc_sub}:environment:production" + } + } + }] + }) +} + +# Broad within this dedicated account by design: the deploy role is what +# applies infra/terraform/prod. The OIDC trust policy above, not this +# permission set, is what keeps it to main + production. +resource "aws_iam_role_policy_attachment" "deploy_admin" { + role = aws_iam_role.deploy.name + policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess" +} diff --git a/infra/terraform/bootstrap/outputs.tf b/infra/terraform/bootstrap/outputs.tf new file mode 100644 index 0000000..4800964 --- /dev/null +++ b/infra/terraform/bootstrap/outputs.tf @@ -0,0 +1,6 @@ +# Copy these into GitHub repository variables (README → One-time setup). +output "AWS_PLAN_ROLE_ARN" { value = aws_iam_role.plan.arn } +output "AWS_DEPLOY_ROLE_ARN" { value = aws_iam_role.deploy.arn } +output "TF_STATE_BUCKET" { value = aws_s3_bucket.state.bucket } +output "ARTIFACT_BUCKET" { value = aws_s3_bucket.artifacts.bucket } +output "AWS_REGION" { value = var.region } diff --git a/infra/terraform/bootstrap/variables.tf b/infra/terraform/bootstrap/variables.tf new file mode 100644 index 0000000..93fa58a --- /dev/null +++ b/infra/terraform/bootstrap/variables.tf @@ -0,0 +1,27 @@ +variable "region" { + type = string + default = "us-east-1" +} + +variable "github_repository" { + description = "owner/name of the repository allowed to assume the CI roles" + type = string + default = "Adron/substratalapps.com" +} + +variable "billing_email" { + description = "Where budget, anomaly, and billing-alarm alerts go" + type = string +} + +variable "warn_budget_usd" { + description = "Warn budget: alerts at 50/80/100%, sized just above the ~$45–55 Tier 0 floor" + type = number + default = 75 +} + +variable "hard_budget_usd" { + description = "Hard budget: the circuit-breaker action fires at 100%" + type = number + default = 150 +} diff --git a/infra/terraform/modules/lambda_function/main.tf b/infra/terraform/modules/lambda_function/main.tf new file mode 100644 index 0000000..ce80495 --- /dev/null +++ b/infra/terraform/modules/lambda_function/main.tf @@ -0,0 +1,109 @@ +# One Lambda function the way every Substratal function is built: Go on +# provided.al2023 / arm64, its own execution role scoped to exactly what it +# needs (DEPLOYMENT.md → Build checklist, step 4: no shared mega-role), +# explicit log retention, and a "live" alias that API Gateway, SQS, and +# the scheduler always invoke. Each deploy publishes a new version and +# moves live to it; rollback.yml moves it back. + +terraform { + required_providers { + aws = { source = "hashicorp/aws", version = "~> 6.0" } + } +} + +variable "name" { type = string } +variable "artifact_bucket" { type = string } +variable "artifact_key" { type = string } +variable "memory_mb" { + type = number + default = 256 +} +variable "timeout_s" { + type = number + default = 30 +} +variable "environment" { + type = map(string) + default = {} +} +variable "policy_statements" { + description = "IAM statements for this function's own role (Resource may be a string or a list, so the type is any)" + type = any + default = [] +} +variable "log_retention_days" { + type = number + default = 30 +} +variable "reserved_concurrency" { + description = "-1 for unreserved" + type = number + default = -1 +} + +resource "aws_iam_role" "this" { + name = var.name + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ Effect = "Allow", Principal = { Service = "lambda.amazonaws.com" }, Action = "sts:AssumeRole" }] + }) +} + +resource "aws_iam_role_policy" "logs" { + role = aws_iam_role.this.id + name = "logs" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["logs:CreateLogStream", "logs:PutLogEvents"] + Resource = "${aws_cloudwatch_log_group.this.arn}:*" + }] + }) +} + +resource "aws_iam_role_policy" "this" { + count = length(var.policy_statements) > 0 ? 1 : 0 + role = aws_iam_role.this.id + name = "function" + policy = jsonencode({ Version = "2012-10-17", Statement = var.policy_statements }) +} + +# Explicit retention: logs left at "never expire" are the classic growing +# CloudWatch bill (DEPLOYMENT.md → Tier 0 components). +resource "aws_cloudwatch_log_group" "this" { + name = "/aws/lambda/${var.name}" + retention_in_days = var.log_retention_days +} + +resource "aws_lambda_function" "this" { + function_name = var.name + role = aws_iam_role.this.arn + runtime = "provided.al2023" + architectures = ["arm64"] + handler = "bootstrap" + s3_bucket = var.artifact_bucket + s3_key = var.artifact_key + memory_size = var.memory_mb + timeout = var.timeout_s + publish = true + reserved_concurrent_executions = var.reserved_concurrency + environment { variables = var.environment } + logging_config { + log_format = "Text" + log_group = aws_cloudwatch_log_group.this.name + } + depends_on = [aws_iam_role_policy.logs] +} + +resource "aws_lambda_alias" "live" { + name = "live" + function_name = aws_lambda_function.this.function_name + function_version = aws_lambda_function.this.version +} + +output "name" { value = aws_lambda_function.this.function_name } +output "alias_arn" { value = aws_lambda_alias.live.arn } +output "alias_invoke_arn" { value = aws_lambda_alias.live.invoke_arn } +output "role_name" { value = aws_iam_role.this.name } +output "log_group" { value = aws_cloudwatch_log_group.this.name } diff --git a/infra/terraform/prod/.terraform.lock.hcl b/infra/terraform/prod/.terraform.lock.hcl new file mode 100644 index 0000000..65f343a --- /dev/null +++ b/infra/terraform/prod/.terraform.lock.hcl @@ -0,0 +1,47 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.67.0" + constraints = "~> 6.0" + hashes = [ + "h1:OgdIUAQDtJBxlKjoPgChD1w57vl6hm6PyJHiBYgsgQA=", + "zh:111d5686a1f4ccbc888bb5e2229308bcdd9149898c6af97969fcdfb0e7bd2aa0", + "zh:21b3b7693bd9754c039fd546f03ed09e7510c6189aa5ee37176f144cf8dd5f95", + "zh:25e03c7f025ff4851889537605aa560d2e39bd738b33a5204b8037eb164b475f", + "zh:2817a046f1060e25bf04b0eb78f4e251130bb92dc82ec1264ce156dc9bf78e67", + "zh:2d318d674c3ec9dbd97ddb10b12ad4827be4f508c43ba2ae1e0523baa9e367e3", + "zh:2f07ab356718267299e10b6072472ded29d82753883027bf43bcd687ac72feb2", + "zh:32307e67f83bc0dc94d38cfcd23d782166a09e0e975e2a5aa7d7a3e7ca5d3da8", + "zh:4ce94853264097dd7f4542b3cfd18d2b98b06d23321db45d5e384ea275a57f63", + "zh:869656c41cc7c7412f213e488f98167cff61deafcb8cf245d25d056e8dfdc263", + "zh:8fd8c814e9d8edf152552047db23bf5b5d63f22e6b0b5d47b2af851376e99349", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9cbc02ceef9bd469da497a1e7065ff984bdacfbe919ac3cce804a86c13b6e2c6", + "zh:9ea55dda2767acfc1f6337fc7d29b1d4d79d6f8f04871f8ad99a6078d2865994", + "zh:d7149df0819fb57a160489db45d33951765b8f0118daa3b4cd549a0135bc97a1", + "zh:e5819937bb7043d08c9829b32d52d9fb55a5b9a4d8d55d550d47a3d7392db546", + "zh:f93bc38dbc0ad53842303f30eec7a22c525c4d56209b54ec33a8d375cfc4e4fd", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.1" + constraints = "~> 3.6" + hashes = [ + "h1:PlW+UZ4EElQF3NQwf41KQwavFujab3Czc51zu9dyVM8=", + "zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7", + "zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26", + "zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2", + "zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1", + "zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb", + "zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f", + "zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa", + "zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097", + "zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b", + "zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc", + "zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c", + ] +} diff --git a/infra/terraform/prod/main.tf b/infra/terraform/prod/main.tf new file mode 100644 index 0000000..874d5f9 --- /dev/null +++ b/infra/terraform/prod/main.tf @@ -0,0 +1,581 @@ +# Production: the Tier 0 stack from DEPLOYMENT.md → First deployment. +# No VPC for Lambda and no NAT Gateway: every function reaches Aurora over +# the RDS Data API (IAM-signed HTTPS), and the webhook worker gets outbound +# internet access for free by staying outside a VPC. + +locals { + prefix = "substratal-prod" + functions = ["api", "mcp", "stripe-webhook", "webhook-worker", "jobs"] + artifact = { for f in local.functions : f => "${var.release}/${f}.zip" } + db_name = "substratal" +} + +data "aws_caller_identity" "me" {} +data "aws_route53_zone" "main" { name = var.hosted_zone } + +# ── Keys (DEPLOYMENT.md → Build checklist, step 3) ───────────────────── + +# JWT signing: the private key never leaves KMS; JWKS publishes its public half. +resource "aws_kms_key" "signing" { + description = "Substratal JWT signing (RS256)" + key_usage = "SIGN_VERIFY" + customer_master_key_spec = "RSA_2048" + deletion_window_in_days = 30 +} + +resource "aws_kms_alias" "signing" { + name = "alias/${local.prefix}-jwt-signing" + target_key_id = aws_kms_key.signing.key_id +} + +# TOTP and webhook signing secrets: the server must read them back. +resource "aws_kms_key" "data" { + description = "Substratal secret encryption (TOTP, webhook secrets)" + enable_key_rotation = true + deletion_window_in_days = 30 +} + +resource "aws_kms_alias" "data" { + name = "alias/${local.prefix}-data" + target_key_id = aws_kms_key.data.key_id +} + +# ── Database: Aurora Serverless v2 with the Data API ─────────────────── + +resource "aws_rds_cluster" "main" { + cluster_identifier = "${local.prefix}-db" + engine = "aurora-postgresql" + engine_mode = "provisioned" + engine_version = "16.6" + database_name = local.db_name + master_username = "substratal" + manage_master_user_password = true # Secrets Manager–managed; the Data API authenticates with it + enable_http_endpoint = true # the Data API + storage_encrypted = true + backup_retention_period = 7 + deletion_protection = true + copy_tags_to_snapshot = true + final_snapshot_identifier = "${local.prefix}-db-final" + serverlessv2_scaling_configuration { + min_capacity = var.db_min_acu + max_capacity = var.db_max_acu + } +} + +resource "aws_rds_cluster_instance" "main" { + identifier = "${local.prefix}-db-1" + cluster_identifier = aws_rds_cluster.main.id + instance_class = "db.serverless" + engine = aws_rds_cluster.main.engine + engine_version = aws_rds_cluster.main.engine_version +} + +locals { + db_secret_arn = aws_rds_cluster.main.master_user_secret[0].secret_arn +} + +# ── Application secret (cursor key, Stripe keys) ─────────────────────── + +resource "random_password" "cursor" { + length = 48 + special = false +} + +resource "random_password" "mcp_session" { + length = 48 + special = false +} + +resource "aws_secretsmanager_secret" "app" { + name = "${local.prefix}/app" + description = "cursor_secret, stripe_secret_key, stripe_webhook_secret. Set the Stripe keys by hand; Terraform never overwrites them." +} + +resource "aws_secretsmanager_secret_version" "app" { + secret_id = aws_secretsmanager_secret.app.id + secret_string = jsonencode({ + cursor_secret = random_password.cursor.result + stripe_secret_key = "" + stripe_webhook_secret = "" + }) + lifecycle { ignore_changes = [secret_string] } +} + +# ── Queues and storage ───────────────────────────────────────────────── + +resource "aws_sqs_queue" "webhooks_dlq" { + name = "${local.prefix}-webhooks-dlq" + message_retention_seconds = 1209600 +} + +# Nudges only: the message is an outbox event id. The worker always drains +# the outbox, so a lost message just waits for the 1-minute sweep. +resource "aws_sqs_queue" "webhooks" { + name = "${local.prefix}-webhooks" + visibility_timeout_seconds = 120 + redrive_policy = jsonencode({ deadLetterTargetArn = aws_sqs_queue.webhooks_dlq.arn, maxReceiveCount = 5 }) +} + +resource "aws_s3_bucket" "audit_archive" { + bucket = "${local.prefix}-audit-archive-${data.aws_caller_identity.me.account_id}" +} + +resource "aws_s3_bucket_public_access_block" "audit_archive" { + bucket = aws_s3_bucket.audit_archive.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "audit_archive" { + bucket = aws_s3_bucket.audit_archive.id + rule { + apply_server_side_encryption_by_default { sse_algorithm = "AES256" } + } +} + +resource "aws_s3_bucket_versioning" "audit_archive" { + bucket = aws_s3_bucket.audit_archive.id + versioning_configuration { status = "Enabled" } +} + +# ── Email (SES) ──────────────────────────────────────────────────────── + +resource "aws_sesv2_email_identity" "mail" { + email_identity = var.mail_domain +} + +resource "aws_route53_record" "dkim" { + count = 3 + zone_id = data.aws_route53_zone.main.zone_id + name = "${aws_sesv2_email_identity.mail.dkim_signing_attributes[0].tokens[count.index]}._domainkey.${var.mail_domain}" + type = "CNAME" + ttl = 1800 + records = ["${aws_sesv2_email_identity.mail.dkim_signing_attributes[0].tokens[count.index]}.dkim.amazonses.com"] +} + +resource "aws_route53_record" "dmarc" { + zone_id = data.aws_route53_zone.main.zone_id + name = "_dmarc.${var.mail_domain}" + type = "TXT" + ttl = 1800 + records = ["v=DMARC1; p=quarantine; rua=mailto:dmarc@${var.hosted_zone}"] +} + +# ── Function permissions ─────────────────────────────────────────────── + +locals { + data_api = [{ + Effect = "Allow" + Action = ["rds-data:ExecuteStatement", "rds-data:BatchExecuteStatement", "rds-data:BeginTransaction", "rds-data:CommitTransaction", "rds-data:RollbackTransaction"] + Resource = aws_rds_cluster.main.arn + }] + secrets = [{ + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = [local.db_secret_arn, aws_secretsmanager_secret.app.arn] + }] + sign = [{ + Effect = "Allow" + Action = ["kms:Sign", "kms:GetPublicKey"] + Resource = aws_kms_key.signing.arn + }] + verify_only = [{ + Effect = "Allow" + Action = ["kms:GetPublicKey"] + Resource = aws_kms_key.signing.arn + }] + crypt = [{ + Effect = "Allow" + Action = ["kms:Encrypt", "kms:Decrypt"] + Resource = aws_kms_key.data.arn + }] + email = [{ + Effect = "Allow" + Action = ["ses:SendEmail"] + Resource = aws_sesv2_email_identity.mail.arn + }] + + common_env = { + APP_ENV = "production" + DATABASE_BACKEND = "dataapi" + DB_CLUSTER_ARN = aws_rds_cluster.main.arn + DB_SECRET_ARN = local.db_secret_arn + DB_NAME = local.db_name + ISSUER = "https://${var.domain_name}" + PUBLIC_BASE_URL = "https://${var.domain_name}" + AUTHORIZE_URL = var.authorize_url + DASHBOARD_URL = var.dashboard_url + KMS_SIGNING_KEY_ID = aws_kms_key.signing.arn + KMS_DATA_KEY_ID = aws_kms_key.data.arn + APP_SECRET_ARN = aws_secretsmanager_secret.app.arn + EMAIL_BACKEND = "ses" + EMAIL_FROM = "Substratal " + } +} + +module "api" { + source = "../modules/lambda_function" + name = "${local.prefix}-api" + artifact_bucket = var.artifact_bucket + artifact_key = local.artifact["api"] + memory_mb = 512 # Argon2id at m=19 MiB per login, with headroom + timeout_s = 29 + # The security log lives here, and NFR → Security logging keeps it a year. + log_retention_days = 365 + environment = merge(local.common_env, { + WEBHOOK_QUEUE_URL = aws_sqs_queue.webhooks.url + CORS_ALLOWED_ORIGINS = var.cors_origins + }) + policy_statements = concat(local.data_api, local.secrets, local.sign, local.crypt, local.email, [{ + Effect = "Allow", Action = ["sqs:SendMessage"], Resource = aws_sqs_queue.webhooks.arn + }]) +} + +# The MCP server holds no data-plane permissions at all: it only calls the +# API's own /v1 routes over HTTPS with the caller's credential. +module "mcp" { + source = "../modules/lambda_function" + name = "${local.prefix}-mcp" + artifact_bucket = var.artifact_bucket + artifact_key = local.artifact["mcp"] + memory_mb = 256 + timeout_s = 29 + environment = { + PUBLIC_BASE_URL = "https://${var.domain_name}" + MCP_SESSION_KEY = random_password.mcp_session.result + } +} + +module "stripe_webhook" { + source = "../modules/lambda_function" + name = "${local.prefix}-stripe-webhook" + artifact_bucket = var.artifact_bucket + artifact_key = local.artifact["stripe-webhook"] + memory_mb = 256 + timeout_s = 29 + environment = local.common_env + policy_statements = concat(local.data_api, local.secrets, local.verify_only, local.crypt) +} + +module "webhook_worker" { + source = "../modules/lambda_function" + name = "${local.prefix}-webhook-worker" + artifact_bucket = var.artifact_bucket + artifact_key = local.artifact["webhook-worker"] + memory_mb = 256 + timeout_s = 110 + reserved_concurrency = 5 # isolates delivery load from the API's blast radius + environment = local.common_env + policy_statements = concat(local.data_api, local.secrets, local.verify_only, local.crypt, local.email, [{ + Effect = "Allow" + Action = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"] + Resource = aws_sqs_queue.webhooks.arn + }]) +} + +module "jobs" { + source = "../modules/lambda_function" + name = "${local.prefix}-jobs" + artifact_bucket = var.artifact_bucket + artifact_key = local.artifact["jobs"] + memory_mb = 512 + timeout_s = 900 + environment = merge(local.common_env, { AUDIT_ARCHIVE_BUCKET = aws_s3_bucket.audit_archive.bucket }) + policy_statements = concat(local.data_api, local.secrets, local.sign, local.crypt, local.email, [{ + Effect = "Allow" + Action = ["s3:PutObject"] + Resource = "${aws_s3_bucket.audit_archive.arn}/*" + }]) +} + +resource "aws_lambda_event_source_mapping" "webhooks" { + event_source_arn = aws_sqs_queue.webhooks.arn + function_name = module.webhook_worker.alias_arn + batch_size = 10 +} + +# ── API Gateway (HTTP API) ───────────────────────────────────────────── + +resource "aws_apigatewayv2_api" "main" { + name = local.prefix + protocol_type = "HTTP" +} + +resource "aws_cloudwatch_log_group" "api_access" { + name = "/aws/apigateway/${local.prefix}" + retention_in_days = 30 +} + +resource "aws_apigatewayv2_stage" "default" { + api_id = aws_apigatewayv2_api.main.id + name = "$default" + auto_deploy = true + default_route_settings { + throttling_rate_limit = var.api_throttle_rate + throttling_burst_limit = var.api_throttle_rate * 2 + } + access_log_settings { + destination_arn = aws_cloudwatch_log_group.api_access.arn + format = jsonencode({ requestId = "$context.requestId", ip = "$context.identity.sourceIp", method = "$context.httpMethod", + path = "$context.path", status = "$context.status", latency = "$context.responseLatency", integration = "$context.integrationErrorMessage" }) + } +} + +locals { + integrations = { + api = module.api + mcp = module.mcp + stripe = module.stripe_webhook + } + routes = { + "ANY /v1/{proxy+}" = "api" + "GET /.well-known/{proxy+}" = "api" + "$default" = "api" + "POST /mcp" = "mcp" + "GET /mcp" = "mcp" + "DELETE /mcp" = "mcp" + "POST /internal/stripe/webhook" = "stripe" + } +} + +resource "aws_apigatewayv2_integration" "fn" { + for_each = local.integrations + api_id = aws_apigatewayv2_api.main.id + integration_type = "AWS_PROXY" + integration_uri = each.value.alias_arn + payload_format_version = "2.0" + timeout_milliseconds = 29000 +} + +resource "aws_apigatewayv2_route" "r" { + for_each = local.routes + api_id = aws_apigatewayv2_api.main.id + route_key = each.key + target = "integrations/${aws_apigatewayv2_integration.fn[each.value].id}" +} + +resource "aws_lambda_permission" "apigw" { + for_each = local.integrations + statement_id = "apigateway-${each.key}" + action = "lambda:InvokeFunction" + function_name = each.value.name + qualifier = "live" + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.main.execution_arn}/*/*" +} + +# ── Custom domain ────────────────────────────────────────────────────── + +resource "aws_acm_certificate" "api" { + domain_name = var.domain_name + validation_method = "DNS" + lifecycle { create_before_destroy = true } +} + +resource "aws_route53_record" "cert_validation" { + for_each = { for o in aws_acm_certificate.api.domain_validation_options : o.domain_name => o } + zone_id = data.aws_route53_zone.main.zone_id + name = each.value.resource_record_name + type = each.value.resource_record_type + records = [each.value.resource_record_value] + ttl = 300 +} + +resource "aws_acm_certificate_validation" "api" { + certificate_arn = aws_acm_certificate.api.arn + validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn] +} + +resource "aws_apigatewayv2_domain_name" "api" { + domain_name = var.domain_name + domain_name_configuration { + certificate_arn = aws_acm_certificate_validation.api.certificate_arn + endpoint_type = "REGIONAL" + security_policy = "TLS_1_2" + } +} + +resource "aws_apigatewayv2_api_mapping" "api" { + api_id = aws_apigatewayv2_api.main.id + domain_name = aws_apigatewayv2_domain_name.api.id + stage = aws_apigatewayv2_stage.default.id +} + +resource "aws_route53_record" "api" { + zone_id = data.aws_route53_zone.main.zone_id + name = var.domain_name + type = "A" + alias { + name = aws_apigatewayv2_domain_name.api.domain_name_configuration[0].target_domain_name + zone_id = aws_apigatewayv2_domain_name.api.domain_name_configuration[0].hosted_zone_id + evaluate_target_health = false + } +} + +# ── Scheduled jobs (EventBridge Scheduler) ───────────────────────────── + +locals { + schedules = { + "entitlement-sweep" = "rate(5 minutes)" + "webhook-dispatch" = "rate(1 minute)" + "erasure-cascade" = "rate(1 hour)" + "webhook-health" = "rate(1 hour)" + "stripe-customers" = "rate(1 hour)" + "stripe-events" = "rate(10 minutes)" + "seat-sync" = "cron(15 0 * * ? *)" + "cleanup" = "cron(0 3 * * ? *)" + "audit-archive" = "cron(30 3 * * ? *)" + "settings-projections" = "cron(0 4 * * ? *)" + } +} + +resource "aws_iam_role" "scheduler" { + name = "${local.prefix}-scheduler" + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ Effect = "Allow", Principal = { Service = "scheduler.amazonaws.com" }, Action = "sts:AssumeRole" }] + }) +} + +resource "aws_iam_role_policy" "scheduler" { + role = aws_iam_role.scheduler.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ Effect = "Allow", Action = "lambda:InvokeFunction", Resource = module.jobs.alias_arn }] + }) +} + +resource "aws_scheduler_schedule" "job" { + for_each = local.schedules + name = "${local.prefix}-${each.key}" + schedule_expression = each.value + schedule_expression_timezone = "UTC" + flexible_time_window { mode = "OFF" } + target { + arn = module.jobs.alias_arn + role_arn = aws_iam_role.scheduler.arn + input = jsonencode({ job = each.key }) + retry_policy { maximum_retry_attempts = 2 } + } +} + +# ── Alarms (DEPLOYMENT.md → Build checklist, step 8) ─────────────────── + +resource "aws_sns_topic" "alarms" { + name = "${local.prefix}-alarms" +} + +resource "aws_sns_topic_subscription" "alarms" { + count = var.alarm_email == "" ? 0 : 1 + topic_arn = aws_sns_topic.alarms.arn + protocol = "email" + endpoint = var.alarm_email +} + +resource "aws_cloudwatch_metric_alarm" "api_5xx" { + alarm_name = "${local.prefix}-api-5xx" + namespace = "AWS/ApiGateway" + metric_name = "5xx" + dimensions = { ApiId = aws_apigatewayv2_api.main.id } + statistic = "Sum" + period = 300 + evaluation_periods = 2 + threshold = 10 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.alarms.arn] +} + +resource "aws_cloudwatch_metric_alarm" "lambda_errors" { + for_each = { api = module.api, mcp = module.mcp, stripe = module.stripe_webhook, worker = module.webhook_worker, jobs = module.jobs } + alarm_name = "${local.prefix}-${each.key}-errors" + namespace = "AWS/Lambda" + metric_name = "Errors" + dimensions = { FunctionName = each.value.name } + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 5 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.alarms.arn] +} + +resource "aws_cloudwatch_metric_alarm" "lambda_throttles" { + for_each = { api = module.api, worker = module.webhook_worker } + alarm_name = "${local.prefix}-${each.key}-throttles" + namespace = "AWS/Lambda" + metric_name = "Throttles" + dimensions = { FunctionName = each.value.name } + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.alarms.arn] +} + +resource "aws_cloudwatch_metric_alarm" "aurora_near_max" { + alarm_name = "${local.prefix}-aurora-acu-near-max" + namespace = "AWS/RDS" + metric_name = "ServerlessDatabaseCapacity" + dimensions = { DBClusterIdentifier = aws_rds_cluster.main.cluster_identifier } + statistic = "Maximum" + period = 300 + evaluation_periods = 3 + threshold = var.db_max_acu * 0.9 + comparison_operator = "GreaterThanOrEqualToThreshold" + alarm_actions = [aws_sns_topic.alarms.arn] +} + +resource "aws_cloudwatch_metric_alarm" "webhook_dlq" { + alarm_name = "${local.prefix}-webhooks-dlq" + namespace = "AWS/SQS" + metric_name = "ApproximateNumberOfMessagesVisible" + dimensions = { QueueName = aws_sqs_queue.webhooks_dlq.name } + statistic = "Maximum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + alarm_actions = [aws_sns_topic.alarms.arn] +} + +# NFR → Security logging: alarm on spikes in failed logins, refresh-token +# reuse, and destructive_operation_restricted. +locals { + security_events = { + failed-logins = "{ $.msg = \"security\" && $.code = \"invalid_credentials\" }" + refresh-reuse = "{ $.msg = \"security\" && $.event = \"refresh_token_reused\" }" + destructive-blocked = "{ $.msg = \"security\" && $.code = \"destructive_operation_restricted\" }" + } +} + +resource "aws_cloudwatch_log_metric_filter" "security" { + for_each = local.security_events + name = "${local.prefix}-${each.key}" + log_group_name = module.api.log_group + pattern = each.value + metric_transformation { + name = each.key + namespace = "Substratal/Security" + value = "1" + } +} + +resource "aws_cloudwatch_metric_alarm" "security" { + for_each = local.security_events + alarm_name = "${local.prefix}-security-${each.key}" + namespace = "Substratal/Security" + metric_name = each.key + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = each.key == "failed-logins" ? 50 : 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.alarms.arn] +} diff --git a/infra/terraform/prod/outputs.tf b/infra/terraform/prod/outputs.tf new file mode 100644 index 0000000..cc5cc80 --- /dev/null +++ b/infra/terraform/prod/outputs.tf @@ -0,0 +1,8 @@ +# deploy.yml reads these to run migrations against the cluster. +output "db_cluster_arn" { value = aws_rds_cluster.main.arn } +output "db_secret_arn" { value = local.db_secret_arn } +output "api_endpoint" { value = aws_apigatewayv2_api.main.api_endpoint } +output "api_url" { value = "https://${var.domain_name}" } +output "app_secret_arn" { value = aws_secretsmanager_secret.app.arn } +output "webhook_queue_url" { value = aws_sqs_queue.webhooks.url } +output "audit_archive_bucket" { value = aws_s3_bucket.audit_archive.bucket } diff --git a/infra/terraform/prod/variables.tf b/infra/terraform/prod/variables.tf new file mode 100644 index 0000000..2ae0a2b --- /dev/null +++ b/infra/terraform/prod/variables.tf @@ -0,0 +1,69 @@ +variable "region" { + type = string + default = "us-east-1" +} + +variable "release" { + description = "Commit SHA being deployed; artifacts live at s3:////.zip" + type = string +} + +variable "artifact_bucket" { type = string } + +variable "domain_name" { + description = "The API's custom domain (Conventions → Base URL)" + type = string + default = "api.substratalapps.com" +} + +variable "hosted_zone" { + description = "Route 53 hosted zone that domain_name and mail_domain live in" + type = string + default = "substratalapps.com" +} + +variable "mail_domain" { + description = "SES sending domain (DKIM/SPF/DMARC)" + type = string + default = "mail.substratalapps.com" +} + +variable "dashboard_url" { + type = string + default = "https://dashboard.substratalapps.com" +} + +variable "authorize_url" { + type = string + default = "https://auth.substratalapps.com/authorize" +} + +variable "cors_origins" { + description = "Comma-separated first-party browser origins (NFR → CORS)" + type = string + default = "https://dashboard.substratalapps.com" +} + +variable "alarm_email" { + description = "Where operational alarms go (empty: create the topic only)" + type = string + default = "" +} + +variable "db_min_acu" { + description = "Aurora Serverless v2 floor; 0.5 is the ~$43/month Tier 0 floor" + type = number + default = 0.5 +} + +variable "db_max_acu" { + description = "Aurora Serverless v2 ceiling, set deliberately (no surprise scale-out)" + type = number + default = 4 +} + +variable "api_throttle_rate" { + description = "API Gateway steady-state requests/second, a cost guard above the app's own rate limits" + type = number + default = 200 +} diff --git a/infra/terraform/prod/versions.tf b/infra/terraform/prod/versions.tf new file mode 100644 index 0000000..50dbdeb --- /dev/null +++ b/infra/terraform/prod/versions.tf @@ -0,0 +1,19 @@ +terraform { + required_version = ">= 1.10" + required_providers { + aws = { source = "hashicorp/aws", version = "~> 6.0" } + random = { source = "hashicorp/random", version = "~> 3.6" } + } + # bucket is passed at init: -backend-config="bucket=". + backend "s3" { + key = "prod/terraform.tfstate" + region = "us-east-1" + use_lockfile = true # native S3 locking; no DynamoDB table + encrypt = true + } +} + +provider "aws" { + region = var.region + default_tags { tags = { project = "substratal", managed_by = "terraform", stack = "prod" } } +} diff --git a/internal/access/access.go b/internal/access/access.go new file mode 100644 index 0000000..72bec6a --- /dev/null +++ b/internal/access/access.go @@ -0,0 +1,207 @@ +// Package access is the Access Control algorithm, as a pure function over +// already-loaded rows (docs/access-control.md → The algorithm): +// +// allow(user, application, permission) := +// user.status == "active" +// AND resolved_entitlement_status(user, application) == "active" +// AND permission ∈ effective_permissions(user, application) +// +// Nothing here touches the database, so every combination of entitlement +// status × platform role × app role can be tested exhaustively and +// cheaply, as NFR → Testing strategy asks. +package access + +import ( + "slices" + "sort" + "time" +) + +// Path is one way a User might reach an Application: their own personal +// Entitlement, or an org-wide grant through an Organization they belong to. +type Path struct { + EntitlementID string + Source string // purchase | trial | admin_grant | org_seat + Status string // active | disabled | expired | revoked + OrganizationID *string + StartsAt time.Time + EndsAt *time.Time + MemberScope *string + MemberOverrides []string + CreatedAt time.Time + + // For org paths: whether the User's membership is active (a pending + // invitation is never a path). A suspended Organization's grants still + // are: suspension freezes the Organization's admin actions but doesn't + // touch members' access (Organizations → PATCH, "What it doesn't do"). + MembershipActive bool + // SeatLimited: the Application is on a Starter Tenant at its seat cap + // and this User isn't already one of its seats (Pricing → Enforcement). + SeatLimited bool +} + +// Decisions an org path can produce for one member (Entitlements → +// Attribution). +const ( + Included = "included" + Excluded = "excluded" + SeatLimit = "seat_limit" +) + +// IsOrg reports whether p is an org-wide grant. +func (p Path) IsOrg() bool { return p.OrganizationID != nil } + +// MemberIncluded is member_included(grant, user). +func MemberIncluded(p Path, userID string) bool { + scope := "all_members" + if p.MemberScope != nil { + scope = *p.MemberScope + } + switch scope { + case "allowlist": + return slices.Contains(p.MemberOverrides, userID) + case "denylist": + return !slices.Contains(p.MemberOverrides, userID) + default: + return true + } +} + +// MemberDecision is granted_via.member_decision for an org path. +func MemberDecision(p Path, userID string) string { + switch { + case !MemberIncluded(p, userID): + return Excluded + case p.SeatLimited: + return SeatLimit + default: + return Included + } +} + +// EffectiveStatus is a row's status as of now: an active or disabled row +// past ends_at is treated as expired immediately, before the sweep writes +// it (Entitlements → Status transitions). +func EffectiveStatus(p Path, now time.Time) string { + if (p.Status == "active" || p.Status == "disabled") && p.EndsAt != nil && !p.EndsAt.After(now) { + return "expired" + } + return p.Status +} + +// pathActive is path_active(e). +func pathActive(p Path, now time.Time) bool { + return EffectiveStatus(p, now) == "active" && !p.StartsAt.After(now) +} + +// counts reports whether p is a path for userID at all: personal paths +// always are; org paths only through an active membership whose grant +// includes the member and isn't seat-limited. +func counts(p Path, userID string) bool { + if !p.IsOrg() { + return true + } + return p.MembershipActive && MemberDecision(p, userID) == Included +} + +// Resolution is resolved_entitlement_status plus its attribution. +type Resolution struct { + Status string // active | scheduled | disabled | expired | revoked | none + // ActiveVia is the path that grants access: personal wins; otherwise + // the org grant created first (Auth → The app token, org_id). + ActiveVia *Path +} + +var precedence = map[string]int{"disabled": 3, "expired": 2, "revoked": 1} + +// Resolve is resolved_entitlement_status(user, application) over every +// path the User has to one Application. +func Resolve(userID string, paths []Path, now time.Time) Resolution { + var active []Path + scheduled := false + worst := "" + for _, p := range paths { + if !counts(p, userID) { + continue + } + if pathActive(p, now) { + active = append(active, p) + continue + } + st := EffectiveStatus(p, now) + if st == "active" && p.StartsAt.After(now) { + scheduled = true + continue + } + if precedence[st] > precedence[worst] { + worst = st + } + } + if len(active) > 0 { + sort.SliceStable(active, func(i, j int) bool { + if active[i].IsOrg() != active[j].IsOrg() { + return !active[i].IsOrg() + } + return active[i].CreatedAt.Before(active[j].CreatedAt) + }) + via := active[0] + return Resolution{Status: "active", ActiveVia: &via} + } + if scheduled { + return Resolution{Status: "scheduled"} + } + if worst != "" { + return Resolution{Status: worst} + } + return Resolution{Status: "none"} +} + +// ActiveVia returns the active path through a specific Organization, for +// app-tokens' organization_id selection. +func ActiveViaOrg(userID, orgID string, paths []Path, now time.Time) (*Path, bool) { + for _, p := range paths { + if p.IsOrg() && *p.OrganizationID == orgID && counts(p, userID) && pathActive(p, now) { + pp := p + return &pp, true + } + } + return nil, false +} + +// Allowed is the first two clauses of allow(): active User and active +// resolved entitlement. The permission clause is EffectivePermissions. +func Allowed(userStatus string, r Resolution) bool { + return userStatus == "active" && r.Status == "active" +} + +// AppRole is one app-scoped Role a User holds for the Application. +type AppRole struct { + ID string + Permissions []string +} + +// EffectivePermissions is effective_permissions(user, application): the +// union of the app-scoped Roles held (explicit assignments plus the +// Application's default_app_role, if set), and empty unless the User and +// the entitlement are both active, so the implicit default role is only +// "held" while access is active. Only app..* keys ever appear, +// because app Roles can only hold their own Application's keys. +func EffectivePermissions(allowed bool, roles []AppRole) (roleIDs, perms []string) { + roleIDs, perms = []string{}, []string{} + if !allowed { + return + } + seen := map[string]bool{} + for _, r := range roles { + roleIDs = append(roleIDs, r.ID) + for _, p := range r.Permissions { + if !seen[p] { + seen[p] = true + perms = append(perms, p) + } + } + } + sort.Strings(roleIDs) + sort.Strings(perms) + return +} diff --git a/internal/access/access_test.go b/internal/access/access_test.go new file mode 100644 index 0000000..8e1b7e7 --- /dev/null +++ b/internal/access/access_test.go @@ -0,0 +1,165 @@ +package access + +import ( + "fmt" + "testing" + "time" +) + +var now = time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) + +func ptr[T any](v T) *T { return &v } + +func personal(status string) Path { + return Path{EntitlementID: "ent_p", Source: "purchase", Status: status, StartsAt: now.Add(-time.Hour), CreatedAt: now.Add(-time.Hour)} +} + +func org(id, status, scope string, overrides ...string) Path { + return Path{EntitlementID: "ent_" + id, Source: "org_seat", Status: status, OrganizationID: ptr(id), + StartsAt: now.Add(-time.Hour), MemberScope: ptr(scope), MemberOverrides: overrides, + MembershipActive: true, CreatedAt: now.Add(-2 * time.Hour)} +} + +// Every combination of user status × personal entitlement status × app +// role set, per NFR → Testing strategy. +func TestAllowExhaustive(t *testing.T) { + userStatuses := []string{"active", "invited", "suspended", "deleted"} + entStatuses := []string{"", "active", "disabled", "expired", "revoked"} + roleSets := [][]AppRole{ + nil, + {{ID: "role_timetrack_member", Permissions: []string{"app.timetrack.view"}}}, + {{ID: "role_timetrack_admin", Permissions: []string{"app.timetrack.export", "app.timetrack.view"}}, + {ID: "role_timetrack_member", Permissions: []string{"app.timetrack.view"}}}, + } + for _, us := range userStatuses { + for _, es := range entStatuses { + for ri, roles := range roleSets { + t.Run(fmt.Sprintf("%s/%s/roles%d", us, es, ri), func(t *testing.T) { + var paths []Path + if es != "" { + paths = append(paths, personal(es)) + } + r := Resolve("usr_1", paths, now) + wantStatus := es + if es == "" { + wantStatus = "none" + } + if r.Status != wantStatus { + t.Fatalf("status = %s, want %s", r.Status, wantStatus) + } + allowed := Allowed(us, r) + if want := us == "active" && es == "active"; allowed != want { + t.Fatalf("allowed = %v, want %v", allowed, want) + } + _, perms := EffectivePermissions(allowed, roles) + if !allowed && len(perms) != 0 { + t.Fatalf("permissions leaked without access: %v", perms) + } + if allowed && len(roles) > 0 && len(perms) == 0 { + t.Fatal("roles held but no permissions") + } + }) + } + } + } +} + +func TestWorkedExampleMultiOrg(t *testing.T) { + // docs/access-control.md → Worked example: multi-org, one Application. + paths := []Path{ + org("org_acme", "active", "all_members"), + org("org_beta", "active", "denylist", "usr_jordan"), + } + r := Resolve("usr_jordan", paths, now) + if r.Status != "active" || *r.ActiveVia.OrganizationID != "org_acme" { + t.Fatalf("want active via org_acme, got %+v", r) + } + // Not a member of org_acme: only org_beta's exclusion remains. + r = Resolve("usr_jordan", paths[1:], now) + if r.Status != "none" { + t.Fatalf("want none, got %s", r.Status) + } +} + +func TestWorkedExampleMemberScope(t *testing.T) { + // docs/domain-model/entitlements.md → who gets access through one org grant. + sam := "usr_sam" + cases := []struct { + scope string + jordan, samAcc, rileyOrg bool + }{ + {"all_members", true, true, true}, + {"allowlist", false, true, false}, + {"denylist", true, false, true}, + } + for _, tc := range cases { + g := org("org_acme", "active", tc.scope, sam) + if got := Resolve("usr_jordan", []Path{g}, now).Status == "active"; got != tc.jordan { + t.Errorf("%s jordan = %v", tc.scope, got) + } + if got := Resolve(sam, []Path{g}, now).Status == "active"; got != tc.samAcc { + t.Errorf("%s sam = %v", tc.scope, got) + } + // Riley holds a personal Entitlement too: always access. + riley := Resolve("usr_riley", []Path{g, personal("active")}, now) + if riley.Status != "active" || riley.ActiveVia.IsOrg() { + t.Errorf("%s riley should be active via personal path, got %+v", tc.scope, riley) + } + if got := MemberDecision(g, "usr_riley") == Included; got != tc.rileyOrg { + t.Errorf("%s riley org decision = %v", tc.scope, got) + } + } +} + +func TestScheduledAndPrecedence(t *testing.T) { + future := personal("active") + future.StartsAt = now.Add(time.Hour) + if s := Resolve("u", []Path{future}, now).Status; s != "scheduled" { + t.Fatalf("future start = %s, want scheduled", s) + } + if s := Resolve("u", []Path{future, personal("disabled")}, now).Status; s != "scheduled" { + t.Fatalf("scheduled beats disabled, got %s", s) + } + paths := []Path{personal("revoked"), personal("expired"), personal("disabled")} + if s := Resolve("u", paths, now).Status; s != "disabled" { + t.Fatalf("precedence = %s, want disabled", s) + } + if s := Resolve("u", paths[:2], now).Status; s != "expired" { + t.Fatalf("precedence = %s, want expired", s) + } + lapsed := personal("active") + lapsed.EndsAt = ptr(now.Add(-time.Second)) + if s := Resolve("u", []Path{lapsed}, now).Status; s != "expired" { + t.Fatalf("ends_at in the past = %s, want expired", s) + } +} + +func TestOrgPathRequiresActiveMembership(t *testing.T) { + g := org("org_a", "active", "all_members") + g.MembershipActive = false + if s := Resolve("u", []Path{g}, now).Status; s != "none" { + t.Fatalf("pending membership = %s, want none", s) + } + g = org("org_a", "active", "all_members") + g.SeatLimited = true + if MemberDecision(g, "u") != SeatLimit { + t.Fatal("seat-limited decision") + } + if s := Resolve("u", []Path{g}, now).Status; s != "none" { + t.Fatalf("seat-limited = %s, want none", s) + } +} + +func TestAttributionPrefersPersonalThenOldestOrg(t *testing.T) { + older := org("org_old", "active", "all_members") + newer := org("org_new", "active", "all_members") + newer.CreatedAt = now.Add(-time.Minute) + r := Resolve("u", []Path{newer, older}, now) + if *r.ActiveVia.OrganizationID != "org_old" { + t.Fatalf("want oldest org grant, got %s", *r.ActiveVia.OrganizationID) + } + r = Resolve("u", []Path{newer, personal("active"), older}, now) + if r.ActiveVia.IsOrg() { + t.Fatal("personal path must win attribution") + } +} diff --git a/internal/api/gen/gen.go b/internal/api/gen/gen.go new file mode 100644 index 0000000..1744d96 --- /dev/null +++ b/internal/api/gen/gen.go @@ -0,0 +1,8208 @@ +//go:build go1.22 + +// Package gen provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT. +package gen + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/oapi-codegen/runtime" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// Defines values for AccessEventReason. +const ( + AccessEventReasonEntitlementDeleted AccessEventReason = "entitlement_deleted" + AccessEventReasonEntitlementDisabled AccessEventReason = "entitlement_disabled" + AccessEventReasonEntitlementEnabled AccessEventReason = "entitlement_enabled" + AccessEventReasonEntitlementExpired AccessEventReason = "entitlement_expired" + AccessEventReasonEntitlementGranted AccessEventReason = "entitlement_granted" + AccessEventReasonEntitlementRevoked AccessEventReason = "entitlement_revoked" + AccessEventReasonEntitlementStarted AccessEventReason = "entitlement_started" + AccessEventReasonOrgGrantChanged AccessEventReason = "org_grant_changed" + AccessEventReasonOrgMemberAdded AccessEventReason = "org_member_added" + AccessEventReasonOrgMemberRemoved AccessEventReason = "org_member_removed" + AccessEventReasonUserDeleted AccessEventReason = "user_deleted" + AccessEventReasonUserReactivated AccessEventReason = "user_reactivated" + AccessEventReasonUserSuspended AccessEventReason = "user_suspended" +) + +// Valid indicates whether the value is a known member of the AccessEventReason enum. +func (e AccessEventReason) Valid() bool { + switch e { + case AccessEventReasonEntitlementDeleted: + return true + case AccessEventReasonEntitlementDisabled: + return true + case AccessEventReasonEntitlementEnabled: + return true + case AccessEventReasonEntitlementExpired: + return true + case AccessEventReasonEntitlementGranted: + return true + case AccessEventReasonEntitlementRevoked: + return true + case AccessEventReasonEntitlementStarted: + return true + case AccessEventReasonOrgGrantChanged: + return true + case AccessEventReasonOrgMemberAdded: + return true + case AccessEventReasonOrgMemberRemoved: + return true + case AccessEventReasonUserDeleted: + return true + case AccessEventReasonUserReactivated: + return true + case AccessEventReasonUserSuspended: + return true + default: + return false + } +} + +// Defines values for ActorType. +const ( + ActorTypeApiKey ActorType = "api_key" + ActorTypeSystem ActorType = "system" + ActorTypeUser ActorType = "user" +) + +// Valid indicates whether the value is a known member of the ActorType enum. +func (e ActorType) Valid() bool { + switch e { + case ActorTypeApiKey: + return true + case ActorTypeSystem: + return true + case ActorTypeUser: + return true + default: + return false + } +} + +// Defines values for ApiKeyCreatedByType. +const ( + ApiKeyCreatedByTypeUser ApiKeyCreatedByType = "user" +) + +// Valid indicates whether the value is a known member of the ApiKeyCreatedByType enum. +func (e ApiKeyCreatedByType) Valid() bool { + switch e { + case ApiKeyCreatedByTypeUser: + return true + default: + return false + } +} + +// Defines values for ApiKeyIntendedUse. +const ( + ApiKeyIntendedUseAgent ApiKeyIntendedUse = "agent" + ApiKeyIntendedUseService ApiKeyIntendedUse = "service" +) + +// Valid indicates whether the value is a known member of the ApiKeyIntendedUse enum. +func (e ApiKeyIntendedUse) Valid() bool { + switch e { + case ApiKeyIntendedUseAgent: + return true + case ApiKeyIntendedUseService: + return true + default: + return false + } +} + +// Defines values for ApiKeyMode. +const ( + ApiKeyModeLive ApiKeyMode = "live" + ApiKeyModeTest ApiKeyMode = "test" +) + +// Valid indicates whether the value is a known member of the ApiKeyMode enum. +func (e ApiKeyMode) Valid() bool { + switch e { + case ApiKeyModeLive: + return true + case ApiKeyModeTest: + return true + default: + return false + } +} + +// Defines values for ApiKeyWithSecretCreatedByType. +const ( + ApiKeyWithSecretCreatedByTypeUser ApiKeyWithSecretCreatedByType = "user" +) + +// Valid indicates whether the value is a known member of the ApiKeyWithSecretCreatedByType enum. +func (e ApiKeyWithSecretCreatedByType) Valid() bool { + switch e { + case ApiKeyWithSecretCreatedByTypeUser: + return true + default: + return false + } +} + +// Defines values for ApiKeyWithSecretIntendedUse. +const ( + ApiKeyWithSecretIntendedUseAgent ApiKeyWithSecretIntendedUse = "agent" + ApiKeyWithSecretIntendedUseService ApiKeyWithSecretIntendedUse = "service" +) + +// Valid indicates whether the value is a known member of the ApiKeyWithSecretIntendedUse enum. +func (e ApiKeyWithSecretIntendedUse) Valid() bool { + switch e { + case ApiKeyWithSecretIntendedUseAgent: + return true + case ApiKeyWithSecretIntendedUseService: + return true + default: + return false + } +} + +// Defines values for ApiKeyWithSecretMode. +const ( + ApiKeyWithSecretModeLive ApiKeyWithSecretMode = "live" + ApiKeyWithSecretModeTest ApiKeyWithSecretMode = "test" +) + +// Valid indicates whether the value is a known member of the ApiKeyWithSecretMode enum. +func (e ApiKeyWithSecretMode) Valid() bool { + switch e { + case ApiKeyWithSecretModeLive: + return true + case ApiKeyWithSecretModeTest: + return true + default: + return false + } +} + +// Defines values for ApiKeyWriteIntendedUse. +const ( + ApiKeyWriteIntendedUseAgent ApiKeyWriteIntendedUse = "agent" + ApiKeyWriteIntendedUseService ApiKeyWriteIntendedUse = "service" +) + +// Valid indicates whether the value is a known member of the ApiKeyWriteIntendedUse enum. +func (e ApiKeyWriteIntendedUse) Valid() bool { + switch e { + case ApiKeyWriteIntendedUseAgent: + return true + case ApiKeyWriteIntendedUseService: + return true + default: + return false + } +} + +// Defines values for ApiKeyWriteMode. +const ( + ApiKeyWriteModeLive ApiKeyWriteMode = "live" + ApiKeyWriteModeTest ApiKeyWriteMode = "test" +) + +// Valid indicates whether the value is a known member of the ApiKeyWriteMode enum. +func (e ApiKeyWriteMode) Valid() bool { + switch e { + case ApiKeyWriteModeLive: + return true + case ApiKeyWriteModeTest: + return true + default: + return false + } +} + +// Defines values for AppSettingsResolvedSources. +const ( + AppDefault AppSettingsResolvedSources = "app_default" + AppOverride AppSettingsResolvedSources = "app_override" + Global AppSettingsResolvedSources = "global" +) + +// Valid indicates whether the value is a known member of the AppSettingsResolvedSources enum. +func (e AppSettingsResolvedSources) Valid() bool { + switch e { + case AppDefault: + return true + case AppOverride: + return true + case Global: + return true + default: + return false + } +} + +// Defines values for AppTokenTokenType. +const ( + AppTokenTokenTypeBearer AppTokenTokenType = "Bearer" +) + +// Valid indicates whether the value is a known member of the AppTokenTokenType enum. +func (e AppTokenTokenType) Valid() bool { + switch e { + case AppTokenTokenTypeBearer: + return true + default: + return false + } +} + +// Defines values for AppTokenClaimsIss. +const ( + HttpsapiSubstratalappsCom AppTokenClaimsIss = "https://api.substratalapps.com" +) + +// Valid indicates whether the value is a known member of the AppTokenClaimsIss enum. +func (e AppTokenClaimsIss) Valid() bool { + switch e { + case HttpsapiSubstratalappsCom: + return true + default: + return false + } +} + +// Defines values for ApplicationReviewStatus. +const ( + ApplicationReviewStatusApproved ApplicationReviewStatus = "approved" + ApplicationReviewStatusPendingReview ApplicationReviewStatus = "pending_review" + ApplicationReviewStatusRejected ApplicationReviewStatus = "rejected" + ApplicationReviewStatusSuspended ApplicationReviewStatus = "suspended" +) + +// Valid indicates whether the value is a known member of the ApplicationReviewStatus enum. +func (e ApplicationReviewStatus) Valid() bool { + switch e { + case ApplicationReviewStatusApproved: + return true + case ApplicationReviewStatusPendingReview: + return true + case ApplicationReviewStatusRejected: + return true + case ApplicationReviewStatusSuspended: + return true + default: + return false + } +} + +// Defines values for ApplicationVisibility. +const ( + Internal ApplicationVisibility = "internal" + InviteOnly ApplicationVisibility = "invite_only" + Public ApplicationVisibility = "public" +) + +// Valid indicates whether the value is a known member of the ApplicationVisibility enum. +func (e ApplicationVisibility) Valid() bool { + switch e { + case Internal: + return true + case InviteOnly: + return true + case Public: + return true + default: + return false + } +} + +// Defines values for AuditAction. +const ( + AuditActionApiKeyCreated AuditAction = "api_key.created" + AuditActionApiKeyRestrictDestructiveDisabled AuditAction = "api_key.restrict_destructive_disabled" + AuditActionApiKeyRevoked AuditAction = "api_key.revoked" + AuditActionApiKeyRotated AuditAction = "api_key.rotated" + AuditActionApiKeyUpdated AuditAction = "api_key.updated" + AuditActionApplicationCreated AuditAction = "application.created" + AuditActionApplicationReviewStatusChanged AuditAction = "application.review_status_changed" + AuditActionApplicationUpdated AuditAction = "application.updated" + AuditActionEntitlementDeleted AuditAction = "entitlement.deleted" + AuditActionEntitlementDisabled AuditAction = "entitlement.disabled" + AuditActionEntitlementExpired AuditAction = "entitlement.expired" + AuditActionEntitlementGranted AuditAction = "entitlement.granted" + AuditActionEntitlementMemberScopeChanged AuditAction = "entitlement.member_scope_changed" + AuditActionEntitlementRevoked AuditAction = "entitlement.revoked" + AuditActionEntitlementUpdated AuditAction = "entitlement.updated" + AuditActionOrganizationCreated AuditAction = "organization.created" + AuditActionOrganizationMemberAdded AuditAction = "organization.member_added" + AuditActionOrganizationMemberInvited AuditAction = "organization.member_invited" + AuditActionOrganizationMemberRemoved AuditAction = "organization.member_removed" + AuditActionOrganizationMemberRoleChanged AuditAction = "organization.member_role_changed" + AuditActionOrganizationUpdated AuditAction = "organization.updated" + AuditActionProfileUpdated AuditAction = "profile.updated" + AuditActionRoleAssigned AuditAction = "role.assigned" + AuditActionRoleCreated AuditAction = "role.created" + AuditActionRoleDeleted AuditAction = "role.deleted" + AuditActionRoleRemoved AuditAction = "role.removed" + AuditActionRoleUpdated AuditAction = "role.updated" + AuditActionSettingsUpdated AuditAction = "settings.updated" + AuditActionTenantPlanChanged AuditAction = "tenant.plan_changed" + AuditActionTenantSubscriptionStatusChanged AuditAction = "tenant.subscription_status_changed" + AuditActionTenantTierChangeRequestUpdated AuditAction = "tenant.tier_change_request_updated" + AuditActionTenantTierChangeRequested AuditAction = "tenant.tier_change_requested" + AuditActionTenantTierChanged AuditAction = "tenant.tier_changed" + AuditActionUserCreated AuditAction = "user.created" + AuditActionUserDeleted AuditAction = "user.deleted" + AuditActionUserEmailChanged AuditAction = "user.email_changed" + AuditActionUserErased AuditAction = "user.erased" + AuditActionUserErasureCancelled AuditAction = "user.erasure_cancelled" + AuditActionUserErasureRequested AuditAction = "user.erasure_requested" + AuditActionUserMfaDisabled AuditAction = "user.mfa_disabled" + AuditActionUserMfaEnabled AuditAction = "user.mfa_enabled" + AuditActionUserMfaReset AuditAction = "user.mfa_reset" + AuditActionUserPasswordChanged AuditAction = "user.password_changed" + AuditActionUserPasswordReset AuditAction = "user.password_reset" + AuditActionUserReactivated AuditAction = "user.reactivated" + AuditActionUserSuspended AuditAction = "user.suspended" + AuditActionUserUpdated AuditAction = "user.updated" + AuditActionWebhookCreated AuditAction = "webhook.created" + AuditActionWebhookDeleted AuditAction = "webhook.deleted" + AuditActionWebhookSecretRotated AuditAction = "webhook.secret_rotated" + AuditActionWebhookUpdated AuditAction = "webhook.updated" +) + +// Valid indicates whether the value is a known member of the AuditAction enum. +func (e AuditAction) Valid() bool { + switch e { + case AuditActionApiKeyCreated: + return true + case AuditActionApiKeyRestrictDestructiveDisabled: + return true + case AuditActionApiKeyRevoked: + return true + case AuditActionApiKeyRotated: + return true + case AuditActionApiKeyUpdated: + return true + case AuditActionApplicationCreated: + return true + case AuditActionApplicationReviewStatusChanged: + return true + case AuditActionApplicationUpdated: + return true + case AuditActionEntitlementDeleted: + return true + case AuditActionEntitlementDisabled: + return true + case AuditActionEntitlementExpired: + return true + case AuditActionEntitlementGranted: + return true + case AuditActionEntitlementMemberScopeChanged: + return true + case AuditActionEntitlementRevoked: + return true + case AuditActionEntitlementUpdated: + return true + case AuditActionOrganizationCreated: + return true + case AuditActionOrganizationMemberAdded: + return true + case AuditActionOrganizationMemberInvited: + return true + case AuditActionOrganizationMemberRemoved: + return true + case AuditActionOrganizationMemberRoleChanged: + return true + case AuditActionOrganizationUpdated: + return true + case AuditActionProfileUpdated: + return true + case AuditActionRoleAssigned: + return true + case AuditActionRoleCreated: + return true + case AuditActionRoleDeleted: + return true + case AuditActionRoleRemoved: + return true + case AuditActionRoleUpdated: + return true + case AuditActionSettingsUpdated: + return true + case AuditActionTenantPlanChanged: + return true + case AuditActionTenantSubscriptionStatusChanged: + return true + case AuditActionTenantTierChangeRequestUpdated: + return true + case AuditActionTenantTierChangeRequested: + return true + case AuditActionTenantTierChanged: + return true + case AuditActionUserCreated: + return true + case AuditActionUserDeleted: + return true + case AuditActionUserEmailChanged: + return true + case AuditActionUserErased: + return true + case AuditActionUserErasureCancelled: + return true + case AuditActionUserErasureRequested: + return true + case AuditActionUserMfaDisabled: + return true + case AuditActionUserMfaEnabled: + return true + case AuditActionUserMfaReset: + return true + case AuditActionUserPasswordChanged: + return true + case AuditActionUserPasswordReset: + return true + case AuditActionUserReactivated: + return true + case AuditActionUserSuspended: + return true + case AuditActionUserUpdated: + return true + case AuditActionWebhookCreated: + return true + case AuditActionWebhookDeleted: + return true + case AuditActionWebhookSecretRotated: + return true + case AuditActionWebhookUpdated: + return true + default: + return false + } +} + +// Defines values for AuditTargetType. +const ( + AuditTargetTypeApiKey AuditTargetType = "api_key" + AuditTargetTypeApplication AuditTargetType = "application" + AuditTargetTypeEntitlement AuditTargetType = "entitlement" + AuditTargetTypeOrganization AuditTargetType = "organization" + AuditTargetTypeRole AuditTargetType = "role" + AuditTargetTypeRoleAssignment AuditTargetType = "role_assignment" + AuditTargetTypeTenant AuditTargetType = "tenant" + AuditTargetTypeTierChangeRequest AuditTargetType = "tier_change_request" + AuditTargetTypeUser AuditTargetType = "user" + AuditTargetTypeWebhook AuditTargetType = "webhook" +) + +// Valid indicates whether the value is a known member of the AuditTargetType enum. +func (e AuditTargetType) Valid() bool { + switch e { + case AuditTargetTypeApiKey: + return true + case AuditTargetTypeApplication: + return true + case AuditTargetTypeEntitlement: + return true + case AuditTargetTypeOrganization: + return true + case AuditTargetTypeRole: + return true + case AuditTargetTypeRoleAssignment: + return true + case AuditTargetTypeTenant: + return true + case AuditTargetTypeTierChangeRequest: + return true + case AuditTargetTypeUser: + return true + case AuditTargetTypeWebhook: + return true + default: + return false + } +} + +// Defines values for AuthSessionTokenType. +const ( + AuthSessionTokenTypeBearer AuthSessionTokenType = "Bearer" +) + +// Valid indicates whether the value is a known member of the AuthSessionTokenType enum. +func (e AuthSessionTokenType) Valid() bool { + switch e { + case AuthSessionTokenTypeBearer: + return true + default: + return false + } +} + +// Defines values for EffectivePermissionsAccessPathsMemberDecision. +const ( + EffectivePermissionsAccessPathsMemberDecisionExcluded EffectivePermissionsAccessPathsMemberDecision = "excluded" + EffectivePermissionsAccessPathsMemberDecisionIncluded EffectivePermissionsAccessPathsMemberDecision = "included" + EffectivePermissionsAccessPathsMemberDecisionLessThannil EffectivePermissionsAccessPathsMemberDecision = "" + EffectivePermissionsAccessPathsMemberDecisionSeatLimit EffectivePermissionsAccessPathsMemberDecision = "seat_limit" +) + +// Valid indicates whether the value is a known member of the EffectivePermissionsAccessPathsMemberDecision enum. +func (e EffectivePermissionsAccessPathsMemberDecision) Valid() bool { + switch e { + case EffectivePermissionsAccessPathsMemberDecisionExcluded: + return true + case EffectivePermissionsAccessPathsMemberDecisionIncluded: + return true + case EffectivePermissionsAccessPathsMemberDecisionLessThannil: + return true + case EffectivePermissionsAccessPathsMemberDecisionSeatLimit: + return true + default: + return false + } +} + +// Defines values for EntitlementGrantedViaMemberDecision. +const ( + EntitlementGrantedViaMemberDecisionExcluded EntitlementGrantedViaMemberDecision = "excluded" + EntitlementGrantedViaMemberDecisionIncluded EntitlementGrantedViaMemberDecision = "included" + EntitlementGrantedViaMemberDecisionSeatLimit EntitlementGrantedViaMemberDecision = "seat_limit" +) + +// Valid indicates whether the value is a known member of the EntitlementGrantedViaMemberDecision enum. +func (e EntitlementGrantedViaMemberDecision) Valid() bool { + switch e { + case EntitlementGrantedViaMemberDecisionExcluded: + return true + case EntitlementGrantedViaMemberDecisionIncluded: + return true + case EntitlementGrantedViaMemberDecisionSeatLimit: + return true + default: + return false + } +} + +// Defines values for EntitlementMemberScope. +const ( + AllMembers EntitlementMemberScope = "all_members" + Allowlist EntitlementMemberScope = "allowlist" + Denylist EntitlementMemberScope = "denylist" +) + +// Valid indicates whether the value is a known member of the EntitlementMemberScope enum. +func (e EntitlementMemberScope) Valid() bool { + switch e { + case AllMembers: + return true + case Allowlist: + return true + case Denylist: + return true + default: + return false + } +} + +// Defines values for EntitlementSource. +const ( + EntitlementSourceAdminGrant EntitlementSource = "admin_grant" + EntitlementSourceOrgSeat EntitlementSource = "org_seat" + EntitlementSourcePurchase EntitlementSource = "purchase" + EntitlementSourceTrial EntitlementSource = "trial" +) + +// Valid indicates whether the value is a known member of the EntitlementSource enum. +func (e EntitlementSource) Valid() bool { + switch e { + case EntitlementSourceAdminGrant: + return true + case EntitlementSourceOrgSeat: + return true + case EntitlementSourcePurchase: + return true + case EntitlementSourceTrial: + return true + default: + return false + } +} + +// Defines values for EntitlementStatus. +const ( + EntitlementStatusActive EntitlementStatus = "active" + EntitlementStatusDisabled EntitlementStatus = "disabled" + EntitlementStatusExpired EntitlementStatus = "expired" + EntitlementStatusRevoked EntitlementStatus = "revoked" +) + +// Valid indicates whether the value is a known member of the EntitlementStatus enum. +func (e EntitlementStatus) Valid() bool { + switch e { + case EntitlementStatusActive: + return true + case EntitlementStatusDisabled: + return true + case EntitlementStatusExpired: + return true + case EntitlementStatusRevoked: + return true + default: + return false + } +} + +// Defines values for ErasureRequestStatus. +const ( + ErasureRequestStatusCancelled ErasureRequestStatus = "cancelled" + ErasureRequestStatusCompleted ErasureRequestStatus = "completed" + ErasureRequestStatusScheduled ErasureRequestStatus = "scheduled" +) + +// Valid indicates whether the value is a known member of the ErasureRequestStatus enum. +func (e ErasureRequestStatus) Valid() bool { + switch e { + case ErasureRequestStatusCancelled: + return true + case ErasureRequestStatusCompleted: + return true + case ErasureRequestStatusScheduled: + return true + default: + return false + } +} + +// Defines values for MfaChallengeMethods. +const ( + RecoveryCode MfaChallengeMethods = "recovery_code" + Totp MfaChallengeMethods = "totp" +) + +// Valid indicates whether the value is a known member of the MfaChallengeMethods enum. +func (e MfaChallengeMethods) Valid() bool { + switch e { + case RecoveryCode: + return true + case Totp: + return true + default: + return false + } +} + +// Defines values for MfaChallengeMfaRequired. +const ( + True MfaChallengeMfaRequired = true +) + +// Valid indicates whether the value is a known member of the MfaChallengeMfaRequired enum. +func (e MfaChallengeMfaRequired) Valid() bool { + switch e { + case True: + return true + default: + return false + } +} + +// Defines values for OAuthErrorError. +const ( + AccessDenied OAuthErrorError = "access_denied" + InvalidClient OAuthErrorError = "invalid_client" + InvalidGrant OAuthErrorError = "invalid_grant" + InvalidRequest OAuthErrorError = "invalid_request" + UnsupportedGrantType OAuthErrorError = "unsupported_grant_type" +) + +// Valid indicates whether the value is a known member of the OAuthErrorError enum. +func (e OAuthErrorError) Valid() bool { + switch e { + case AccessDenied: + return true + case InvalidClient: + return true + case InvalidGrant: + return true + case InvalidRequest: + return true + case UnsupportedGrantType: + return true + default: + return false + } +} + +// Defines values for OAuthTokenRequestGrantType. +const ( + AuthorizationCode OAuthTokenRequestGrantType = "authorization_code" + RefreshToken OAuthTokenRequestGrantType = "refresh_token" +) + +// Valid indicates whether the value is a known member of the OAuthTokenRequestGrantType enum. +func (e OAuthTokenRequestGrantType) Valid() bool { + switch e { + case AuthorizationCode: + return true + case RefreshToken: + return true + default: + return false + } +} + +// Defines values for OrganizationStatus. +const ( + OrganizationStatusActive OrganizationStatus = "active" + OrganizationStatusSuspended OrganizationStatus = "suspended" +) + +// Valid indicates whether the value is a known member of the OrganizationStatus enum. +func (e OrganizationStatus) Valid() bool { + switch e { + case OrganizationStatusActive: + return true + case OrganizationStatusSuspended: + return true + default: + return false + } +} + +// Defines values for OrganizationMemberRole. +const ( + Member OrganizationMemberRole = "member" + OrgAdmin OrganizationMemberRole = "org_admin" +) + +// Valid indicates whether the value is a known member of the OrganizationMemberRole enum. +func (e OrganizationMemberRole) Valid() bool { + switch e { + case Member: + return true + case OrgAdmin: + return true + default: + return false + } +} + +// Defines values for OrganizationMembershipStatus. +const ( + OrganizationMembershipStatusActive OrganizationMembershipStatus = "active" + OrganizationMembershipStatusPending OrganizationMembershipStatus = "pending" +) + +// Valid indicates whether the value is a known member of the OrganizationMembershipStatus enum. +func (e OrganizationMembershipStatus) Valid() bool { + switch e { + case OrganizationMembershipStatusActive: + return true + case OrganizationMembershipStatusPending: + return true + default: + return false + } +} + +// Defines values for Region. +const ( + ApSoutheast2 Region = "ap-southeast-2" + CaCentral1 Region = "ca-central-1" + EuCentral1 Region = "eu-central-1" + EuWest1 Region = "eu-west-1" + UsEast1 Region = "us-east-1" + UsWest2 Region = "us-west-2" +) + +// Valid indicates whether the value is a known member of the Region enum. +func (e Region) Valid() bool { + switch e { + case ApSoutheast2: + return true + case CaCentral1: + return true + case EuCentral1: + return true + case EuWest1: + return true + case UsEast1: + return true + case UsWest2: + return true + default: + return false + } +} + +// Defines values for ResolvedEntitlementStatus. +const ( + ResolvedEntitlementStatusActive ResolvedEntitlementStatus = "active" + ResolvedEntitlementStatusDisabled ResolvedEntitlementStatus = "disabled" + ResolvedEntitlementStatusExpired ResolvedEntitlementStatus = "expired" + ResolvedEntitlementStatusNone ResolvedEntitlementStatus = "none" + ResolvedEntitlementStatusRevoked ResolvedEntitlementStatus = "revoked" + ResolvedEntitlementStatusScheduled ResolvedEntitlementStatus = "scheduled" +) + +// Valid indicates whether the value is a known member of the ResolvedEntitlementStatus enum. +func (e ResolvedEntitlementStatus) Valid() bool { + switch e { + case ResolvedEntitlementStatusActive: + return true + case ResolvedEntitlementStatusDisabled: + return true + case ResolvedEntitlementStatusExpired: + return true + case ResolvedEntitlementStatusNone: + return true + case ResolvedEntitlementStatusRevoked: + return true + case ResolvedEntitlementStatusScheduled: + return true + default: + return false + } +} + +// Defines values for SettingsTheme. +const ( + SettingsThemeDark SettingsTheme = "dark" + SettingsThemeLight SettingsTheme = "light" + SettingsThemeSystem SettingsTheme = "system" +) + +// Valid indicates whether the value is a known member of the SettingsTheme enum. +func (e SettingsTheme) Valid() bool { + switch e { + case SettingsThemeDark: + return true + case SettingsThemeLight: + return true + case SettingsThemeSystem: + return true + default: + return false + } +} + +// Defines values for SettingsWriteTheme. +const ( + SettingsWriteThemeDark SettingsWriteTheme = "dark" + SettingsWriteThemeLight SettingsWriteTheme = "light" + SettingsWriteThemeSystem SettingsWriteTheme = "system" +) + +// Valid indicates whether the value is a known member of the SettingsWriteTheme enum. +func (e SettingsWriteTheme) Valid() bool { + switch e { + case SettingsWriteThemeDark: + return true + case SettingsWriteThemeLight: + return true + case SettingsWriteThemeSystem: + return true + default: + return false + } +} + +// Defines values for SubscriptionAddOnsCode. +const ( + DedicatedRegionTenancy SubscriptionAddOnsCode = "dedicated_region_tenancy" + IsolatedTenancy SubscriptionAddOnsCode = "isolated_tenancy" +) + +// Valid indicates whether the value is a known member of the SubscriptionAddOnsCode enum. +func (e SubscriptionAddOnsCode) Valid() bool { + switch e { + case DedicatedRegionTenancy: + return true + case IsolatedTenancy: + return true + default: + return false + } +} + +// Defines values for SubscriptionCurrency. +const ( + Usd SubscriptionCurrency = "usd" +) + +// Valid indicates whether the value is a known member of the SubscriptionCurrency enum. +func (e SubscriptionCurrency) Valid() bool { + switch e { + case Usd: + return true + default: + return false + } +} + +// Defines values for SubscriptionStatus. +const ( + SubscriptionStatusActive SubscriptionStatus = "active" + SubscriptionStatusCanceled SubscriptionStatus = "canceled" + SubscriptionStatusIncomplete SubscriptionStatus = "incomplete" + SubscriptionStatusIncompleteExpired SubscriptionStatus = "incomplete_expired" + SubscriptionStatusNone SubscriptionStatus = "none" + SubscriptionStatusPastDue SubscriptionStatus = "past_due" + SubscriptionStatusPaused SubscriptionStatus = "paused" + SubscriptionStatusTrialing SubscriptionStatus = "trialing" + SubscriptionStatusUnpaid SubscriptionStatus = "unpaid" +) + +// Valid indicates whether the value is a known member of the SubscriptionStatus enum. +func (e SubscriptionStatus) Valid() bool { + switch e { + case SubscriptionStatusActive: + return true + case SubscriptionStatusCanceled: + return true + case SubscriptionStatusIncomplete: + return true + case SubscriptionStatusIncompleteExpired: + return true + case SubscriptionStatusNone: + return true + case SubscriptionStatusPastDue: + return true + case SubscriptionStatusPaused: + return true + case SubscriptionStatusTrialing: + return true + case SubscriptionStatusUnpaid: + return true + default: + return false + } +} + +// Defines values for TenantOwnerType. +const ( + TenantOwnerTypeOrganization TenantOwnerType = "organization" + TenantOwnerTypeUser TenantOwnerType = "user" +) + +// Valid indicates whether the value is a known member of the TenantOwnerType enum. +func (e TenantOwnerType) Valid() bool { + switch e { + case TenantOwnerTypeOrganization: + return true + case TenantOwnerTypeUser: + return true + default: + return false + } +} + +// Defines values for TenantPlan. +const ( + TenantPlanEnterprise TenantPlan = "enterprise" + TenantPlanStarter TenantPlan = "starter" + TenantPlanTeam TenantPlan = "team" +) + +// Valid indicates whether the value is a known member of the TenantPlan enum. +func (e TenantPlan) Valid() bool { + switch e { + case TenantPlanEnterprise: + return true + case TenantPlanStarter: + return true + case TenantPlanTeam: + return true + default: + return false + } +} + +// Defines values for TenantStatus. +const ( + TenantStatusActive TenantStatus = "active" + TenantStatusMigrating TenantStatus = "migrating" + TenantStatusSuspended TenantStatus = "suspended" +) + +// Valid indicates whether the value is a known member of the TenantStatus enum. +func (e TenantStatus) Valid() bool { + switch e { + case TenantStatusActive: + return true + case TenantStatusMigrating: + return true + case TenantStatusSuspended: + return true + default: + return false + } +} + +// Defines values for TenantTier. +const ( + TenantTierDedicatedRegion TenantTier = "dedicated_region" + TenantTierIsolated TenantTier = "isolated" + TenantTierShared TenantTier = "shared" +) + +// Valid indicates whether the value is a known member of the TenantTier enum. +func (e TenantTier) Valid() bool { + switch e { + case TenantTierDedicatedRegion: + return true + case TenantTierIsolated: + return true + case TenantTierShared: + return true + default: + return false + } +} + +// Defines values for TenantTierChangeRequestStatus. +const ( + TenantTierChangeRequestStatusCancelled TenantTierChangeRequestStatus = "cancelled" + TenantTierChangeRequestStatusCompleted TenantTierChangeRequestStatus = "completed" + TenantTierChangeRequestStatusInProgress TenantTierChangeRequestStatus = "in_progress" + TenantTierChangeRequestStatusPending TenantTierChangeRequestStatus = "pending" + TenantTierChangeRequestStatusScheduled TenantTierChangeRequestStatus = "scheduled" +) + +// Valid indicates whether the value is a known member of the TenantTierChangeRequestStatus enum. +func (e TenantTierChangeRequestStatus) Valid() bool { + switch e { + case TenantTierChangeRequestStatusCancelled: + return true + case TenantTierChangeRequestStatusCompleted: + return true + case TenantTierChangeRequestStatusInProgress: + return true + case TenantTierChangeRequestStatusPending: + return true + case TenantTierChangeRequestStatusScheduled: + return true + default: + return false + } +} + +// Defines values for UserExportIdentitiesMethod. +const ( + Password UserExportIdentitiesMethod = "password" + Sso UserExportIdentitiesMethod = "sso" +) + +// Valid indicates whether the value is a known member of the UserExportIdentitiesMethod enum. +func (e UserExportIdentitiesMethod) Valid() bool { + switch e { + case Password: + return true + case Sso: + return true + default: + return false + } +} + +// Defines values for UserStatus. +const ( + UserStatusActive UserStatus = "active" + UserStatusDeleted UserStatus = "deleted" + UserStatusInvited UserStatus = "invited" + UserStatusSuspended UserStatus = "suspended" +) + +// Valid indicates whether the value is a known member of the UserStatus enum. +func (e UserStatus) Valid() bool { + switch e { + case UserStatusActive: + return true + case UserStatusDeleted: + return true + case UserStatusInvited: + return true + case UserStatusSuspended: + return true + default: + return false + } +} + +// Defines values for WebhookStatus. +const ( + WebhookStatusDisabled WebhookStatus = "disabled" + WebhookStatusHealthy WebhookStatus = "healthy" + WebhookStatusUnhealthy WebhookStatus = "unhealthy" +) + +// Valid indicates whether the value is a known member of the WebhookStatus enum. +func (e WebhookStatus) Valid() bool { + switch e { + case WebhookStatusDisabled: + return true + case WebhookStatusHealthy: + return true + case WebhookStatusUnhealthy: + return true + default: + return false + } +} + +// Defines values for WebhookDeliveryStatus. +const ( + WebhookDeliveryStatusFailed WebhookDeliveryStatus = "failed" + WebhookDeliveryStatusPending WebhookDeliveryStatus = "pending" + WebhookDeliveryStatusSucceeded WebhookDeliveryStatus = "succeeded" +) + +// Valid indicates whether the value is a known member of the WebhookDeliveryStatus enum. +func (e WebhookDeliveryStatus) Valid() bool { + switch e { + case WebhookDeliveryStatusFailed: + return true + case WebhookDeliveryStatusPending: + return true + case WebhookDeliveryStatusSucceeded: + return true + default: + return false + } +} + +// Defines values for WebhookEvent. +const ( + WebhookEventAccessGranted WebhookEvent = "access.granted" + WebhookEventAccessRevoked WebhookEvent = "access.revoked" + WebhookEventApplicationReviewStatusChanged WebhookEvent = "application.review_status_changed" + WebhookEventEntitlementDeleted WebhookEvent = "entitlement.deleted" + WebhookEventEntitlementDisabled WebhookEvent = "entitlement.disabled" + WebhookEventEntitlementExpired WebhookEvent = "entitlement.expired" + WebhookEventEntitlementGranted WebhookEvent = "entitlement.granted" + WebhookEventEntitlementMemberScopeChanged WebhookEvent = "entitlement.member_scope_changed" + WebhookEventEntitlementRevoked WebhookEvent = "entitlement.revoked" + WebhookEventEntitlementUpdated WebhookEvent = "entitlement.updated" + WebhookEventOrganizationMemberAdded WebhookEvent = "organization.member_added" + WebhookEventOrganizationMemberRemoved WebhookEvent = "organization.member_removed" + WebhookEventRoleAssigned WebhookEvent = "role.assigned" + WebhookEventRoleRemoved WebhookEvent = "role.removed" + WebhookEventUserDeleted WebhookEvent = "user.deleted" + WebhookEventUserReactivated WebhookEvent = "user.reactivated" + WebhookEventUserSuspended WebhookEvent = "user.suspended" + WebhookEventWebhookTest WebhookEvent = "webhook.test" +) + +// Valid indicates whether the value is a known member of the WebhookEvent enum. +func (e WebhookEvent) Valid() bool { + switch e { + case WebhookEventAccessGranted: + return true + case WebhookEventAccessRevoked: + return true + case WebhookEventApplicationReviewStatusChanged: + return true + case WebhookEventEntitlementDeleted: + return true + case WebhookEventEntitlementDisabled: + return true + case WebhookEventEntitlementExpired: + return true + case WebhookEventEntitlementGranted: + return true + case WebhookEventEntitlementMemberScopeChanged: + return true + case WebhookEventEntitlementRevoked: + return true + case WebhookEventEntitlementUpdated: + return true + case WebhookEventOrganizationMemberAdded: + return true + case WebhookEventOrganizationMemberRemoved: + return true + case WebhookEventRoleAssigned: + return true + case WebhookEventRoleRemoved: + return true + case WebhookEventUserDeleted: + return true + case WebhookEventUserReactivated: + return true + case WebhookEventUserSuspended: + return true + case WebhookEventWebhookTest: + return true + default: + return false + } +} + +// Defines values for WebhookWithSecretStatus. +const ( + WebhookWithSecretStatusDisabled WebhookWithSecretStatus = "disabled" + WebhookWithSecretStatusHealthy WebhookWithSecretStatus = "healthy" + WebhookWithSecretStatusUnhealthy WebhookWithSecretStatus = "unhealthy" +) + +// Valid indicates whether the value is a known member of the WebhookWithSecretStatus enum. +func (e WebhookWithSecretStatus) Valid() bool { + switch e { + case WebhookWithSecretStatusDisabled: + return true + case WebhookWithSecretStatusHealthy: + return true + case WebhookWithSecretStatusUnhealthy: + return true + default: + return false + } +} + +// Defines values for ApiKeysListParamsMode. +const ( + ApiKeysListParamsModeLive ApiKeysListParamsMode = "live" + ApiKeysListParamsModeTest ApiKeysListParamsMode = "test" +) + +// Valid indicates whether the value is a known member of the ApiKeysListParamsMode enum. +func (e ApiKeysListParamsMode) Valid() bool { + switch e { + case ApiKeysListParamsModeLive: + return true + case ApiKeysListParamsModeTest: + return true + default: + return false + } +} + +// Defines values for ApiKeysListParamsIntendedUse. +const ( + ApiKeysListParamsIntendedUseAgent ApiKeysListParamsIntendedUse = "agent" + ApiKeysListParamsIntendedUseService ApiKeysListParamsIntendedUse = "service" +) + +// Valid indicates whether the value is a known member of the ApiKeysListParamsIntendedUse enum. +func (e ApiKeysListParamsIntendedUse) Valid() bool { + switch e { + case ApiKeysListParamsIntendedUseAgent: + return true + case ApiKeysListParamsIntendedUseService: + return true + default: + return false + } +} + +// Defines values for AuditListParamsActorType. +const ( + AuditListParamsActorTypeApiKey AuditListParamsActorType = "api_key" + AuditListParamsActorTypeSystem AuditListParamsActorType = "system" + AuditListParamsActorTypeUser AuditListParamsActorType = "user" +) + +// Valid indicates whether the value is a known member of the AuditListParamsActorType enum. +func (e AuditListParamsActorType) Valid() bool { + switch e { + case AuditListParamsActorTypeApiKey: + return true + case AuditListParamsActorTypeSystem: + return true + case AuditListParamsActorTypeUser: + return true + default: + return false + } +} + +// Defines values for AuthCreateAuthorizationCodeJSONBodyCodeChallengeMethod. +const ( + S256 AuthCreateAuthorizationCodeJSONBodyCodeChallengeMethod = "S256" +) + +// Valid indicates whether the value is a known member of the AuthCreateAuthorizationCodeJSONBodyCodeChallengeMethod enum. +func (e AuthCreateAuthorizationCodeJSONBodyCodeChallengeMethod) Valid() bool { + switch e { + case S256: + return true + default: + return false + } +} + +// Defines values for AuthExchangeOAuthToken200JSONResponseBodyTokenType. +const ( + AuthExchangeOAuthToken200JSONResponseBodyTokenTypeBearer AuthExchangeOAuthToken200JSONResponseBodyTokenType = "Bearer" +) + +// Valid indicates whether the value is a known member of the AuthExchangeOAuthToken200JSONResponseBodyTokenType enum. +func (e AuthExchangeOAuthToken200JSONResponseBodyTokenType) Valid() bool { + switch e { + case AuthExchangeOAuthToken200JSONResponseBodyTokenTypeBearer: + return true + default: + return false + } +} + +// Defines values for EntitlementsUpdateJSONBodySource. +const ( + EntitlementsUpdateJSONBodySourcePurchase EntitlementsUpdateJSONBodySource = "purchase" +) + +// Valid indicates whether the value is a known member of the EntitlementsUpdateJSONBodySource enum. +func (e EntitlementsUpdateJSONBodySource) Valid() bool { + switch e { + case EntitlementsUpdateJSONBodySourcePurchase: + return true + default: + return false + } +} + +// Defines values for HealthCheck200JSONResponseBodyStatus. +const ( + Ok HealthCheck200JSONResponseBodyStatus = "ok" +) + +// Valid indicates whether the value is a known member of the HealthCheck200JSONResponseBodyStatus enum. +func (e HealthCheck200JSONResponseBodyStatus) Valid() bool { + switch e { + case Ok: + return true + default: + return false + } +} + +// Defines values for OrganizationsListParamsStatus. +const ( + OrganizationsListParamsStatusActive OrganizationsListParamsStatus = "active" + OrganizationsListParamsStatusSuspended OrganizationsListParamsStatus = "suspended" +) + +// Valid indicates whether the value is a known member of the OrganizationsListParamsStatus enum. +func (e OrganizationsListParamsStatus) Valid() bool { + switch e { + case OrganizationsListParamsStatusActive: + return true + case OrganizationsListParamsStatusSuspended: + return true + default: + return false + } +} + +// Defines values for OrganizationsUpdateJSONBodyStatus. +const ( + OrganizationsUpdateJSONBodyStatusActive OrganizationsUpdateJSONBodyStatus = "active" + OrganizationsUpdateJSONBodyStatusSuspended OrganizationsUpdateJSONBodyStatus = "suspended" +) + +// Valid indicates whether the value is a known member of the OrganizationsUpdateJSONBodyStatus enum. +func (e OrganizationsUpdateJSONBodyStatus) Valid() bool { + switch e { + case OrganizationsUpdateJSONBodyStatusActive: + return true + case OrganizationsUpdateJSONBodyStatusSuspended: + return true + default: + return false + } +} + +// Defines values for BillingCreateCheckoutSessionJSONBodyPlan. +const ( + BillingCreateCheckoutSessionJSONBodyPlanTeam BillingCreateCheckoutSessionJSONBodyPlan = "team" +) + +// Valid indicates whether the value is a known member of the BillingCreateCheckoutSessionJSONBodyPlan enum. +func (e BillingCreateCheckoutSessionJSONBodyPlan) Valid() bool { + switch e { + case BillingCreateCheckoutSessionJSONBodyPlanTeam: + return true + default: + return false + } +} + +// Defines values for TenantsRequestTierChangeJSONBodyRequestedTier. +const ( + TenantsRequestTierChangeJSONBodyRequestedTierDedicatedRegion TenantsRequestTierChangeJSONBodyRequestedTier = "dedicated_region" + TenantsRequestTierChangeJSONBodyRequestedTierIsolated TenantsRequestTierChangeJSONBodyRequestedTier = "isolated" +) + +// Valid indicates whether the value is a known member of the TenantsRequestTierChangeJSONBodyRequestedTier enum. +func (e TenantsRequestTierChangeJSONBodyRequestedTier) Valid() bool { + switch e { + case TenantsRequestTierChangeJSONBodyRequestedTierDedicatedRegion: + return true + case TenantsRequestTierChangeJSONBodyRequestedTierIsolated: + return true + default: + return false + } +} + +// Defines values for TenantsUpdateTierChangeRequestJSONBodyStatus. +const ( + TenantsUpdateTierChangeRequestJSONBodyStatusCancelled TenantsUpdateTierChangeRequestJSONBodyStatus = "cancelled" + TenantsUpdateTierChangeRequestJSONBodyStatusCompleted TenantsUpdateTierChangeRequestJSONBodyStatus = "completed" + TenantsUpdateTierChangeRequestJSONBodyStatusInProgress TenantsUpdateTierChangeRequestJSONBodyStatus = "in_progress" + TenantsUpdateTierChangeRequestJSONBodyStatusScheduled TenantsUpdateTierChangeRequestJSONBodyStatus = "scheduled" +) + +// Valid indicates whether the value is a known member of the TenantsUpdateTierChangeRequestJSONBodyStatus enum. +func (e TenantsUpdateTierChangeRequestJSONBodyStatus) Valid() bool { + switch e { + case TenantsUpdateTierChangeRequestJSONBodyStatusCancelled: + return true + case TenantsUpdateTierChangeRequestJSONBodyStatusCompleted: + return true + case TenantsUpdateTierChangeRequestJSONBodyStatusInProgress: + return true + case TenantsUpdateTierChangeRequestJSONBodyStatusScheduled: + return true + default: + return false + } +} + +// Defines values for UsersListParamsStatus. +const ( + UsersListParamsStatusActive UsersListParamsStatus = "active" + UsersListParamsStatusDeleted UsersListParamsStatus = "deleted" + UsersListParamsStatusInvited UsersListParamsStatus = "invited" + UsersListParamsStatusSuspended UsersListParamsStatus = "suspended" +) + +// Valid indicates whether the value is a known member of the UsersListParamsStatus enum. +func (e UsersListParamsStatus) Valid() bool { + switch e { + case UsersListParamsStatusActive: + return true + case UsersListParamsStatusDeleted: + return true + case UsersListParamsStatusInvited: + return true + case UsersListParamsStatusSuspended: + return true + default: + return false + } +} + +// Defines values for UsersCreateJSONBodyStatus. +const ( + UsersCreateJSONBodyStatusActive UsersCreateJSONBodyStatus = "active" + UsersCreateJSONBodyStatusInvited UsersCreateJSONBodyStatus = "invited" +) + +// Valid indicates whether the value is a known member of the UsersCreateJSONBodyStatus enum. +func (e UsersCreateJSONBodyStatus) Valid() bool { + switch e { + case UsersCreateJSONBodyStatusActive: + return true + case UsersCreateJSONBodyStatusInvited: + return true + default: + return false + } +} + +// Defines values for UsersUpdateJSONBodyStatus. +const ( + UsersUpdateJSONBodyStatusActive UsersUpdateJSONBodyStatus = "active" + UsersUpdateJSONBodyStatusSuspended UsersUpdateJSONBodyStatus = "suspended" +) + +// Valid indicates whether the value is a known member of the UsersUpdateJSONBodyStatus enum. +func (e UsersUpdateJSONBodyStatus) Valid() bool { + switch e { + case UsersUpdateJSONBodyStatusActive: + return true + case UsersUpdateJSONBodyStatusSuspended: + return true + default: + return false + } +} + +// Defines values for EntitlementsGrantJSONBodySource. +const ( + EntitlementsGrantJSONBodySourceAdminGrant EntitlementsGrantJSONBodySource = "admin_grant" + EntitlementsGrantJSONBodySourcePurchase EntitlementsGrantJSONBodySource = "purchase" + EntitlementsGrantJSONBodySourceTrial EntitlementsGrantJSONBodySource = "trial" +) + +// Valid indicates whether the value is a known member of the EntitlementsGrantJSONBodySource enum. +func (e EntitlementsGrantJSONBodySource) Valid() bool { + switch e { + case EntitlementsGrantJSONBodySourceAdminGrant: + return true + case EntitlementsGrantJSONBodySourcePurchase: + return true + case EntitlementsGrantJSONBodySourceTrial: + return true + default: + return false + } +} + +// Defines values for WebhooksCreateJSONBodyEvents1. +const ( + Asterisk WebhooksCreateJSONBodyEvents1 = "*" +) + +// Valid indicates whether the value is a known member of the WebhooksCreateJSONBodyEvents1 enum. +func (e WebhooksCreateJSONBodyEvents1) Valid() bool { + switch e { + case Asterisk: + return true + default: + return false + } +} + +// Defines values for WebhooksUpdateJSONBodyStatus. +const ( + WebhooksUpdateJSONBodyStatusHealthy WebhooksUpdateJSONBodyStatus = "healthy" +) + +// Valid indicates whether the value is a known member of the WebhooksUpdateJSONBodyStatus enum. +func (e WebhooksUpdateJSONBodyStatus) Valid() bool { + switch e { + case WebhooksUpdateJSONBodyStatusHealthy: + return true + default: + return false + } +} + +// Defines values for WebhooksListDeliveriesParamsStatus. +const ( + WebhooksListDeliveriesParamsStatusFailed WebhooksListDeliveriesParamsStatus = "failed" + WebhooksListDeliveriesParamsStatusPending WebhooksListDeliveriesParamsStatus = "pending" + WebhooksListDeliveriesParamsStatusSucceeded WebhooksListDeliveriesParamsStatus = "succeeded" +) + +// Valid indicates whether the value is a known member of the WebhooksListDeliveriesParamsStatus enum. +func (e WebhooksListDeliveriesParamsStatus) Valid() bool { + switch e { + case WebhooksListDeliveriesParamsStatusFailed: + return true + case WebhooksListDeliveriesParamsStatusPending: + return true + case WebhooksListDeliveriesParamsStatusSucceeded: + return true + default: + return false + } +} + +// AccessEventReason defines model for AccessEventReason. +type AccessEventReason string + +// Actor defines model for Actor. +type Actor struct { + Id string `json:"id"` + Type ActorType `json:"type"` +} + +// ActorType defines model for Actor.Type. +type ActorType string + +// ApiKey defines model for ApiKey. +type ApiKey struct { + CreatedAt *time.Time `json:"created_at,omitempty"` + + // CreatedBy Always a User: API Keys can't create API Keys. + CreatedBy *struct { + Id *string `json:"id,omitempty"` + Type *ApiKeyCreatedByType `json:"type,omitempty"` + } `json:"created_by,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Id *string `json:"id,omitempty"` + + // IntendedUse Sets the default for restrict_destructive (true for agent, false for service). + IntendedUse *ApiKeyIntendedUse `json:"intended_use,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + + // Mode Fixed at creation. + Mode ApiKeyMode `json:"mode"` + Name string `json:"name"` + Permissions []string `json:"permissions"` + + // RestrictDestructive When true, every operation marked x-substratal-destructive is rejected with 403 destructive_operation_restricted. + RestrictDestructive *bool `json:"restrict_destructive,omitempty"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + + // Scope 'platform' or an application_id + Scope string `json:"scope"` + SecretHint *string `json:"secret_hint,omitempty"` +} + +// ApiKeyCreatedByType defines model for ApiKey.CreatedBy.Type. +type ApiKeyCreatedByType string + +// ApiKeyIntendedUse Sets the default for restrict_destructive (true for agent, false for service). +type ApiKeyIntendedUse string + +// ApiKeyMode Fixed at creation. +type ApiKeyMode string + +// ApiKeyPage defines model for ApiKeyPage. +type ApiKeyPage struct { + Data []ApiKey `json:"data"` + Page PageInfo `json:"page"` +} + +// ApiKeyWithSecret defines model for ApiKeyWithSecret. +type ApiKeyWithSecret struct { + CreatedAt *time.Time `json:"created_at,omitempty"` + + // CreatedBy Always a User: API Keys can't create API Keys. + CreatedBy *struct { + Id *string `json:"id,omitempty"` + Type *ApiKeyWithSecretCreatedByType `json:"type,omitempty"` + } `json:"created_by,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Id *string `json:"id,omitempty"` + + // IntendedUse Sets the default for restrict_destructive (true for agent, false for service). + IntendedUse *ApiKeyWithSecretIntendedUse `json:"intended_use,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + + // Mode Fixed at creation. + Mode ApiKeyWithSecretMode `json:"mode"` + Name string `json:"name"` + Permissions []string `json:"permissions"` + + // RestrictDestructive When true, every operation marked x-substratal-destructive is rejected with 403 destructive_operation_restricted. + RestrictDestructive *bool `json:"restrict_destructive,omitempty"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + + // Scope 'platform' or an application_id + Scope string `json:"scope"` + Secret *string `json:"secret,omitempty"` + SecretHint *string `json:"secret_hint,omitempty"` +} + +// ApiKeyWithSecretCreatedByType defines model for ApiKeyWithSecret.CreatedBy.Type. +type ApiKeyWithSecretCreatedByType string + +// ApiKeyWithSecretIntendedUse Sets the default for restrict_destructive (true for agent, false for service). +type ApiKeyWithSecretIntendedUse string + +// ApiKeyWithSecretMode Fixed at creation. +type ApiKeyWithSecretMode string + +// ApiKeyWrite defines model for ApiKeyWrite. +type ApiKeyWrite struct { + ExpiresAt *time.Time `json:"expires_at,omitempty"` + + // IntendedUse Sets the default for restrict_destructive (true for agent, false for service). + IntendedUse *ApiKeyWriteIntendedUse `json:"intended_use,omitempty"` + + // Mode Fixed at creation. + Mode ApiKeyWriteMode `json:"mode"` + Name string `json:"name"` + Permissions []string `json:"permissions"` + + // RestrictDestructive When true, every operation marked x-substratal-destructive is rejected with 403 destructive_operation_restricted. + RestrictDestructive *bool `json:"restrict_destructive,omitempty"` + + // Scope 'platform' or an application_id + Scope string `json:"scope"` +} + +// ApiKeyWriteIntendedUse Sets the default for restrict_destructive (true for agent, false for service). +type ApiKeyWriteIntendedUse string + +// ApiKeyWriteMode Fixed at creation. +type ApiKeyWriteMode string + +// AppProfile defines model for AppProfile. +type AppProfile struct { + ApplicationId *string `json:"application_id,omitempty"` + Custom *map[string]interface{} `json:"custom,omitempty"` + DisplayHandle *string `json:"display_handle,omitempty"` + EffectiveDisplayName *string `json:"effective_display_name,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + UserId *string `json:"user_id,omitempty"` +} + +// AppSettingsResolved defines model for AppSettingsResolved. +type AppSettingsResolved struct { + ApplicationId *string `json:"application_id,omitempty"` + Overrides *map[string]interface{} `json:"overrides,omitempty"` + Resolved *map[string]interface{} `json:"resolved,omitempty"` + Sources *map[string]AppSettingsResolvedSources `json:"sources,omitempty"` + StaleOverrides *[]string `json:"stale_overrides,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + UserId *string `json:"user_id,omitempty"` +} + +// AppSettingsResolvedSources defines model for AppSettingsResolved.Sources. +type AppSettingsResolvedSources string + +// AppToken defines model for AppToken. +type AppToken struct { + // AppToken RS256 JWT; aud = application_id; 5-minute TTL. Claims: see AppTokenClaims. + AppToken string `json:"app_token"` + ApplicationId string `json:"application_id"` + ExpiresIn int `json:"expires_in"` + TokenType AppTokenTokenType `json:"token_type"` +} + +// AppTokenTokenType defines model for AppToken.TokenType. +type AppTokenTokenType string + +// AppTokenClaims Decoded payload of an app token, for Application implementers. +type AppTokenClaims struct { + Aud *string `json:"aud,omitempty"` + EffectivePermissions *[]string `json:"effective_permissions,omitempty"` + Email *string `json:"email,omitempty"` + EmailVerified *bool `json:"email_verified,omitempty"` + + // EntitlementStatus scheduled: the only live path is a grant whose starts_at is in the future. + EntitlementStatus *ResolvedEntitlementStatus `json:"entitlement_status,omitempty"` + Exp *int `json:"exp,omitempty"` + Iat *int `json:"iat,omitempty"` + Iss *AppTokenClaimsIss `json:"iss,omitempty"` + Jti *string `json:"jti,omitempty"` + OrgId *string `json:"org_id,omitempty"` + Sid *string `json:"sid,omitempty"` + Sub *string `json:"sub,omitempty"` + TestMode *bool `json:"test_mode,omitempty"` +} + +// AppTokenClaimsIss defines model for AppTokenClaims.Iss. +type AppTokenClaimsIss string + +// Application defines model for Application. +type Application struct { + AvailableAppRoles *[]string `json:"available_app_roles,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + DefaultAppRole *string `json:"default_app_role,omitempty"` + Description *string `json:"description,omitempty"` + EmailFromName *string `json:"email_from_name,omitempty"` + IconUrl *string `json:"icon_url,omitempty"` + Id *string `json:"id,omitempty"` + LaunchUrl *string `json:"launch_url,omitempty"` + Name *string `json:"name,omitempty"` + OwnerOrganizationId *string `json:"owner_organization_id,omitempty"` + OwnerUserId *string `json:"owner_user_id,omitempty"` + Permissions *[]ApplicationPermission `json:"permissions,omitempty"` + RedirectUris *[]string `json:"redirect_uris,omitempty"` + ReviewNotes *string `json:"review_notes,omitempty"` + ReviewStatus *ApplicationReviewStatus `json:"review_status,omitempty"` + + // SettingsSchema JSON Schema 2020-12 subset — see Domain Model → Settings → settings_schema rules + SettingsSchema *map[string]interface{} `json:"settings_schema,omitempty"` + SettingsSchemaStats *struct { + StaleOverrideCounts *map[string]int `json:"stale_override_counts,omitempty"` + } `json:"settings_schema_stats,omitempty"` + + // Slug Create only; immutable. 'platform' is reserved (rejected with validation_failed, code unknown_value). The Application's id is app_. + Slug *string `json:"slug,omitempty"` + SupportUrl *string `json:"support_url,omitempty"` + TenantId *string `json:"tenant_id,omitempty"` + TestMode *bool `json:"test_mode,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Visibility *ApplicationVisibility `json:"visibility,omitempty"` +} + +// ApplicationPermission defines model for ApplicationPermission. +type ApplicationPermission struct { + Description *string `json:"description,omitempty"` + Key string `json:"key"` +} + +// ApplicationReviewStatus defines model for ApplicationReviewStatus. +type ApplicationReviewStatus string + +// ApplicationSummary defines model for ApplicationSummary. +type ApplicationSummary struct { + Description *string `json:"description,omitempty"` + IconUrl *string `json:"icon_url,omitempty"` + Id *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + ReviewStatus *ApplicationReviewStatus `json:"review_status,omitempty"` + Slug *string `json:"slug,omitempty"` + Visibility *ApplicationVisibility `json:"visibility,omitempty"` +} + +// ApplicationSummaryPage defines model for ApplicationSummaryPage. +type ApplicationSummaryPage struct { + Data []ApplicationSummary `json:"data"` + Page PageInfo `json:"page"` +} + +// ApplicationVisibility defines model for ApplicationVisibility. +type ApplicationVisibility string + +// ApplicationWrite defines model for ApplicationWrite. +type ApplicationWrite struct { + AvailableAppRoles *[]string `json:"available_app_roles,omitempty"` + DefaultAppRole *string `json:"default_app_role,omitempty"` + Description *string `json:"description,omitempty"` + EmailFromName *string `json:"email_from_name,omitempty"` + IconUrl *string `json:"icon_url,omitempty"` + LaunchUrl *string `json:"launch_url,omitempty"` + Name *string `json:"name,omitempty"` + OwnerOrganizationId *string `json:"owner_organization_id,omitempty"` + OwnerUserId *string `json:"owner_user_id,omitempty"` + Permissions *[]ApplicationPermission `json:"permissions,omitempty"` + RedirectUris *[]string `json:"redirect_uris,omitempty"` + ReviewNotes *string `json:"review_notes,omitempty"` + ReviewStatus *ApplicationReviewStatus `json:"review_status,omitempty"` + + // SettingsSchema JSON Schema 2020-12 subset — see Domain Model → Settings → settings_schema rules + SettingsSchema *map[string]interface{} `json:"settings_schema,omitempty"` + + // Slug Create only; immutable. 'platform' is reserved (rejected with validation_failed, code unknown_value). The Application's id is app_. + Slug *string `json:"slug,omitempty"` + SupportUrl *string `json:"support_url,omitempty"` + Visibility *ApplicationVisibility `json:"visibility,omitempty"` +} + +// AuditAction defines model for AuditAction. +type AuditAction string + +// AuditEvent defines model for AuditEvent. +type AuditEvent struct { + Action *AuditAction `json:"action,omitempty"` + Actor *Actor `json:"actor,omitempty"` + After *map[string]interface{} `json:"after,omitempty"` + ApplicationId *string `json:"application_id,omitempty"` + Before *map[string]interface{} `json:"before,omitempty"` + Id *string `json:"id,omitempty"` + OrganizationId *string `json:"organization_id,omitempty"` + RequestId *string `json:"request_id,omitempty"` + Target *struct { + Id *string `json:"id,omitempty"` + Type *AuditTargetType `json:"type,omitempty"` + } `json:"target,omitempty"` + TargetUserId *string `json:"target_user_id,omitempty"` + TenantId *string `json:"tenant_id,omitempty"` + Timestamp *time.Time `json:"timestamp,omitempty"` +} + +// AuditEventPage defines model for AuditEventPage. +type AuditEventPage struct { + Data []AuditEvent `json:"data"` + Page PageInfo `json:"page"` +} + +// AuditTargetType defines model for AuditTargetType. +type AuditTargetType string + +// AuthSession defines model for AuthSession. +type AuthSession struct { + AccessToken string `json:"access_token"` + EmailVerified *bool `json:"email_verified,omitempty"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + RefreshTokenExpiresIn *int `json:"refresh_token_expires_in,omitempty"` + SessionId string `json:"session_id"` + TokenType AuthSessionTokenType `json:"token_type"` + UserId string `json:"user_id"` +} + +// AuthSessionTokenType defines model for AuthSession.TokenType. +type AuthSessionTokenType string + +// EffectivePermissions defines model for EffectivePermissions. +type EffectivePermissions struct { + AccessPaths *[]struct { + EntitlementId *string `json:"entitlement_id,omitempty"` + MemberDecision *EffectivePermissionsAccessPathsMemberDecision `json:"member_decision,omitempty"` + OrganizationId *string `json:"organization_id,omitempty"` + Source *EntitlementSource `json:"source,omitempty"` + Status *EntitlementStatus `json:"status,omitempty"` + } `json:"access_paths,omitempty"` + Allowed bool `json:"allowed"` + ApplicationId string `json:"application_id"` + ComputedAt *time.Time `json:"computed_at,omitempty"` + EffectivePermissions []string `json:"effective_permissions"` + + // EntitlementStatus scheduled: the only live path is a grant whose starts_at is in the future. + EntitlementStatus ResolvedEntitlementStatus `json:"entitlement_status"` + Roles *[]string `json:"roles,omitempty"` + UserId string `json:"user_id"` + UserStatus UserStatus `json:"user_status"` +} + +// EffectivePermissionsAccessPathsMemberDecision defines model for EffectivePermissions.AccessPaths.MemberDecision. +type EffectivePermissionsAccessPathsMemberDecision string + +// Entitlement defines model for Entitlement. +type Entitlement struct { + ApplicationId *string `json:"application_id,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + DisabledReason *string `json:"disabled_reason,omitempty"` + EndsAt *time.Time `json:"ends_at,omitempty"` + + // GrantedVia Computed per member at read time; present only when reading a specific member's view of an org-wide grant. + GrantedVia *EntitlementGrantedVia `json:"granted_via,omitempty"` + Id *string `json:"id,omitempty"` + IncludedMemberCount *int `json:"included_member_count,omitempty"` + MemberOverrides *[]string `json:"member_overrides,omitempty"` + MemberScope *EntitlementMemberScope `json:"member_scope,omitempty"` + + // OrderId Opaque developer-supplied billing reference + OrderId *string `json:"order_id,omitempty"` + OrganizationId *string `json:"organization_id,omitempty"` + Source *EntitlementSource `json:"source,omitempty"` + StartsAt *time.Time `json:"starts_at,omitempty"` + Status *EntitlementStatus `json:"status,omitempty"` + TestMode *bool `json:"test_mode,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + UserId *string `json:"user_id,omitempty"` +} + +// EntitlementGrantedVia Computed per member at read time; present only when reading a specific member's view of an org-wide grant. +type EntitlementGrantedVia struct { + MemberDecision *EntitlementGrantedViaMemberDecision `json:"member_decision,omitempty"` + OrganizationId *string `json:"organization_id,omitempty"` +} + +// EntitlementGrantedViaMemberDecision defines model for EntitlementGrantedVia.MemberDecision. +type EntitlementGrantedViaMemberDecision string + +// EntitlementMemberScope defines model for EntitlementMemberScope. +type EntitlementMemberScope string + +// EntitlementPage defines model for EntitlementPage. +type EntitlementPage struct { + Data []Entitlement `json:"data"` + Page PageInfo `json:"page"` +} + +// EntitlementSource defines model for EntitlementSource. +type EntitlementSource string + +// EntitlementStatus defines model for EntitlementStatus. +type EntitlementStatus string + +// ErasureRequest defines model for ErasureRequest. +type ErasureRequest struct { + RequestedAt *time.Time `json:"requested_at,omitempty"` + ScheduledFor *time.Time `json:"scheduled_for,omitempty"` + Status *ErasureRequestStatus `json:"status,omitempty"` + UserId *string `json:"user_id,omitempty"` +} + +// ErasureRequestStatus defines model for ErasureRequest.Status. +type ErasureRequestStatus string + +// Error defines model for Error. +type Error struct { + Error struct { + Code string `json:"code"` + Details *Error_Error_Details `json:"details,omitempty"` + Message string `json:"message"` + } `json:"error"` +} + +// Error_Error_Details defines model for Error.Error.Details. +type Error_Error_Details struct { + Fields *[]struct { + Code string `json:"code"` + Field string `json:"field"` + } `json:"fields,omitempty"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// MfaChallenge defines model for MfaChallenge. +type MfaChallenge struct { + ExpiresIn int `json:"expires_in"` + Methods []MfaChallengeMethods `json:"methods"` + MfaRequired MfaChallengeMfaRequired `json:"mfa_required"` + MfaToken string `json:"mfa_token"` +} + +// MfaChallengeMethods defines model for MfaChallenge.Methods. +type MfaChallengeMethods string + +// MfaChallengeMfaRequired defines model for MfaChallenge.MfaRequired. +type MfaChallengeMfaRequired bool + +// OAuthError defines model for OAuthError. +type OAuthError struct { + Error OAuthErrorError `json:"error"` + ErrorDescription *string `json:"error_description,omitempty"` +} + +// OAuthErrorError defines model for OAuthError.Error. +type OAuthErrorError string + +// OAuthTokenRequest defines model for OAuthTokenRequest. +type OAuthTokenRequest struct { + // ClientId The application_id + ClientId string `json:"client_id"` + Code *string `json:"code,omitempty"` + CodeVerifier *string `json:"code_verifier,omitempty"` + GrantType OAuthTokenRequestGrantType `json:"grant_type"` + RedirectUri *string `json:"redirect_uri,omitempty"` + RefreshToken *string `json:"refresh_token,omitempty"` +} + +// OAuthTokenRequestGrantType defines model for OAuthTokenRequest.GrantType. +type OAuthTokenRequestGrantType string + +// Organization defines model for Organization. +type Organization struct { + CreatedAt *time.Time `json:"created_at,omitempty"` + CreatedBy *string `json:"created_by,omitempty"` + Id *string `json:"id,omitempty"` + + // MemberCount Active members only + MemberCount *int `json:"member_count,omitempty"` + MyMembershipStatus *OrganizationMembershipStatus `json:"my_membership_status,omitempty"` + MyRole *OrganizationMemberRole `json:"my_role,omitempty"` + Name *string `json:"name,omitempty"` + Status *OrganizationStatus `json:"status,omitempty"` + TestMode *bool `json:"test_mode,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// OrganizationStatus defines model for Organization.Status. +type OrganizationStatus string + +// OrganizationMember defines model for OrganizationMember. +type OrganizationMember struct { + // DisplayName null while the membership is pending + DisplayName *string `json:"display_name,omitempty"` + Email *openapi_types.Email `json:"email,omitempty"` + InvitedAt *time.Time `json:"invited_at,omitempty"` + JoinedAt *time.Time `json:"joined_at,omitempty"` + MembershipStatus *OrganizationMembershipStatus `json:"membership_status,omitempty"` + Role *OrganizationMemberRole `json:"role,omitempty"` + UserId *string `json:"user_id,omitempty"` +} + +// OrganizationMemberPage defines model for OrganizationMemberPage. +type OrganizationMemberPage struct { + Data []OrganizationMember `json:"data"` + Page PageInfo `json:"page"` +} + +// OrganizationMemberRole defines model for OrganizationMemberRole. +type OrganizationMemberRole string + +// OrganizationMembershipStatus defines model for OrganizationMembershipStatus. +type OrganizationMembershipStatus string + +// OrganizationPage defines model for OrganizationPage. +type OrganizationPage struct { + Data []Organization `json:"data"` + Page PageInfo `json:"page"` +} + +// PageEnvelope defines model for PageEnvelope. +type PageEnvelope struct { + Page PageInfo `json:"page"` +} + +// PageInfo defines model for PageInfo. +type PageInfo struct { + HasMore bool `json:"has_more"` + NextCursor *string `json:"next_cursor"` +} + +// Permission defines model for Permission. +type Permission struct { + Description *string `json:"description,omitempty"` + Key *string `json:"key,omitempty"` + Scope *string `json:"scope,omitempty"` +} + +// PermissionPage defines model for PermissionPage. +type PermissionPage struct { + Data []Permission `json:"data"` + Page PageInfo `json:"page"` +} + +// Profile defines model for Profile. +type Profile struct { + AvatarUrl *string `json:"avatar_url,omitempty"` + ContactEmail *openapi_types.Email `json:"contact_email,omitempty"` + ContactPhone *string `json:"contact_phone,omitempty"` + DisplayName *string `json:"display_name,omitempty"` + + // Locale Mirror of the global Settings locale; write it there. + Locale *string `json:"locale,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + UserId *string `json:"user_id,omitempty"` +} + +// ProfileWrite defines model for ProfileWrite. +type ProfileWrite struct { + AvatarUrl *string `json:"avatar_url,omitempty"` + ContactEmail *openapi_types.Email `json:"contact_email,omitempty"` + ContactPhone *string `json:"contact_phone,omitempty"` + DisplayName *string `json:"display_name,omitempty"` +} + +// RedirectSession defines model for RedirectSession. +type RedirectSession struct { + ExpiresAt time.Time `json:"expires_at"` + Url string `json:"url"` +} + +// Region defines model for Region. +type Region string + +// ResolvedEntitlementStatus scheduled: the only live path is a grant whose starts_at is in the future. +type ResolvedEntitlementStatus string + +// Role defines model for Role. +type Role struct { + AssignmentCount *int `json:"assignment_count,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + Description *string `json:"description,omitempty"` + + // Id role_platform_ or role__ + Id *string `json:"id,omitempty"` + Name string `json:"name"` + Permissions []string `json:"permissions"` + + // Scope 'platform' or an application_id + Scope string `json:"scope"` + Seed *bool `json:"seed,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// RoleCreate defines model for RoleCreate. +type RoleCreate struct { + Description *string `json:"description,omitempty"` + Name string `json:"name"` + Permissions []string `json:"permissions"` + + // Scope 'platform' or an application_id + Scope string `json:"scope"` +} + +// RolePage defines model for RolePage. +type RolePage struct { + Data []Role `json:"data"` + Page PageInfo `json:"page"` +} + +// Session defines model for Session. +type Session struct { + Amr *[]string `json:"amr,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + Current *bool `json:"current,omitempty"` + + // ExpiresAt The earlier of the idle expiry (30 days after the last refresh) and the absolute expiry (90 days after creation). + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Id *string `json:"id,omitempty"` + IpAddress *string `json:"ip_address,omitempty"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + UserAgent *string `json:"user_agent,omitempty"` +} + +// Settings defines model for Settings. +type Settings struct { + Locale *string `json:"locale,omitempty"` + Notifications *map[string]*bool `json:"notifications,omitempty"` + Theme *SettingsTheme `json:"theme,omitempty"` + Timezone *string `json:"timezone,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + UserId *string `json:"user_id,omitempty"` +} + +// SettingsTheme defines model for Settings.Theme. +type SettingsTheme string + +// SettingsWrite defines model for SettingsWrite. +type SettingsWrite struct { + Locale *string `json:"locale,omitempty"` + Notifications *map[string]*bool `json:"notifications,omitempty"` + Theme *SettingsWriteTheme `json:"theme,omitempty"` + Timezone *string `json:"timezone,omitempty"` +} + +// SettingsWriteTheme defines model for SettingsWrite.Theme. +type SettingsWriteTheme string + +// Subscription defines model for Subscription. +type Subscription struct { + AddOns *[]struct { + // Amount Cents per month + Amount *int `json:"amount,omitempty"` + Code *SubscriptionAddOnsCode `json:"code,omitempty"` + } `json:"add_ons,omitempty"` + CancelAtPeriodEnd *bool `json:"cancel_at_period_end,omitempty"` + Currency *SubscriptionCurrency `json:"currency,omitempty"` + CurrentPeriodEnd *time.Time `json:"current_period_end,omitempty"` + CurrentPeriodStart *time.Time `json:"current_period_start,omitempty"` + + // Plan Commercial plan, independent of tier. isolated/dedicated_region require enterprise; starter requires a user owner. + Plan *TenantPlan `json:"plan,omitempty"` + Restricted *bool `json:"restricted,omitempty"` + Seats *struct { + Billable *int `json:"billable,omitempty"` + Current *int `json:"current,omitempty"` + Included *int `json:"included,omitempty"` + LastSyncedAt *time.Time `json:"last_synced_at,omitempty"` + } `json:"seats,omitempty"` + SubscriptionStatus *SubscriptionStatus `json:"subscription_status,omitempty"` + TenantId *string `json:"tenant_id,omitempty"` +} + +// SubscriptionAddOnsCode defines model for Subscription.AddOns.Code. +type SubscriptionAddOnsCode string + +// SubscriptionCurrency defines model for Subscription.Currency. +type SubscriptionCurrency string + +// SubscriptionStatus defines model for SubscriptionStatus. +type SubscriptionStatus string + +// Tenant defines model for Tenant. +type Tenant struct { + ApplicationCount *int `json:"application_count,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + Id *string `json:"id,omitempty"` + OwnerOrganizationId *string `json:"owner_organization_id,omitempty"` + OwnerType *TenantOwnerType `json:"owner_type,omitempty"` + OwnerUserId *string `json:"owner_user_id,omitempty"` + + // Plan Commercial plan, independent of tier. isolated/dedicated_region require enterprise; starter requires a user owner. + Plan *TenantPlan `json:"plan,omitempty"` + Region *Region `json:"region,omitempty"` + Restricted *bool `json:"restricted,omitempty"` + Status *TenantStatus `json:"status,omitempty"` + SubscriptionStatus *SubscriptionStatus `json:"subscription_status,omitempty"` + Tier *TenantTier `json:"tier,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// TenantOwnerType defines model for TenantOwnerType. +type TenantOwnerType string + +// TenantPage defines model for TenantPage. +type TenantPage struct { + Data []Tenant `json:"data"` + Page PageInfo `json:"page"` +} + +// TenantPlan Commercial plan, independent of tier. isolated/dedicated_region require enterprise; starter requires a user owner. +type TenantPlan string + +// TenantStatus defines model for TenantStatus. +type TenantStatus string + +// TenantTier defines model for TenantTier. +type TenantTier string + +// TenantTierChangeRequest defines model for TenantTierChangeRequest. +type TenantTierChangeRequest struct { + CompletedAt *time.Time `json:"completed_at,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + FromRegion *Region `json:"from_region,omitempty"` + FromTier *TenantTier `json:"from_tier,omitempty"` + Id *string `json:"id,omitempty"` + Notes *string `json:"notes,omitempty"` + Reason *string `json:"reason,omitempty"` + RequestedBy *string `json:"requested_by,omitempty"` + RequestedRegion *Region `json:"requested_region,omitempty"` + RequestedTier *TenantTier `json:"requested_tier,omitempty"` + ScheduledFor *time.Time `json:"scheduled_for,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + Status *TenantTierChangeRequestStatus `json:"status,omitempty"` + TenantId *string `json:"tenant_id,omitempty"` +} + +// TenantTierChangeRequestPage defines model for TenantTierChangeRequestPage. +type TenantTierChangeRequestPage struct { + Data []TenantTierChangeRequest `json:"data"` + Page PageInfo `json:"page"` +} + +// TenantTierChangeRequestStatus defines model for TenantTierChangeRequestStatus. +type TenantTierChangeRequestStatus string + +// TenantUsage defines model for TenantUsage. +type TenantUsage struct { + FitsPlans *[]TenantPlan `json:"fits_plans,omitempty"` + + // Limits Keys: applications, seats, app_roles (current = highest count on any one Application), webhooks, audit_hot_window_days. The same names as plan_limit_reached's details.resource. + Limits *map[string]struct { + Current *int `json:"current,omitempty"` + Included *int `json:"included,omitempty"` + Limit *int `json:"limit,omitempty"` + } `json:"limits,omitempty"` + + // Plan Commercial plan, independent of tier. isolated/dedicated_region require enterprise; starter requires a user owner. + Plan *TenantPlan `json:"plan,omitempty"` + TenantId *string `json:"tenant_id,omitempty"` +} + +// User defines model for User. +type User struct { + CreatedAt *time.Time `json:"created_at,omitempty"` + Email *openapi_types.Email `json:"email,omitempty"` + EmailVerified *bool `json:"email_verified,omitempty"` + Id *string `json:"id,omitempty"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` + MfaEnabled *bool `json:"mfa_enabled,omitempty"` + PendingEmail *openapi_types.Email `json:"pending_email,omitempty"` + SignupApplicationId *string `json:"signup_application_id,omitempty"` + Status *UserStatus `json:"status,omitempty"` + TestMode *bool `json:"test_mode,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// UserExport Reads from the same tables the hard-delete cascade writes to — deliberately symmetric with it. +type UserExport struct { + Applications *[]struct { + AppProfile *AppProfile `json:"app_profile,omitempty"` + AppSettings *struct { + Overrides *map[string]interface{} `json:"overrides,omitempty"` + } `json:"app_settings,omitempty"` + ApplicationId *string `json:"application_id,omitempty"` + Entitlement *Entitlement `json:"entitlement,omitempty"` + } `json:"applications,omitempty"` + + // AuditEvents This user's own trail as target_user_id (hot window). + AuditEvents *[]AuditEvent `json:"audit_events,omitempty"` + ExportedAt *time.Time `json:"exported_at,omitempty"` + + // Identities Never includes password_hash or mfa_secret. + Identities *[]struct { + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + Method *UserExportIdentitiesMethod `json:"method,omitempty"` + MfaEnabled *bool `json:"mfa_enabled,omitempty"` + } `json:"identities,omitempty"` + Organizations *[]struct { + JoinedAt *time.Time `json:"joined_at,omitempty"` + OrganizationId *string `json:"organization_id,omitempty"` + Role *OrganizationMemberRole `json:"role,omitempty"` + } `json:"organizations,omitempty"` + Profile *Profile `json:"profile,omitempty"` + Roles *[]UserRoleAssignment `json:"roles,omitempty"` + Sessions *[]Session `json:"sessions,omitempty"` + Settings *Settings `json:"settings,omitempty"` + User *User `json:"user,omitempty"` +} + +// UserExportIdentitiesMethod defines model for UserExport.Identities.Method. +type UserExportIdentitiesMethod string + +// UserPage defines model for UserPage. +type UserPage struct { + Data []User `json:"data"` + Page PageInfo `json:"page"` +} + +// UserRoleAssignment defines model for UserRoleAssignment. +type UserRoleAssignment struct { + ApplicationId *string `json:"application_id,omitempty"` + AssignedAt *time.Time `json:"assigned_at,omitempty"` + AssignedBy *Actor `json:"assigned_by,omitempty"` + Implicit *bool `json:"implicit,omitempty"` + RoleId *string `json:"role_id,omitempty"` + UserId *string `json:"user_id,omitempty"` +} + +// UserRoleAssignmentPage defines model for UserRoleAssignmentPage. +type UserRoleAssignmentPage struct { + Data []UserRoleAssignment `json:"data"` + Page PageInfo `json:"page"` +} + +// UserStatus defines model for UserStatus. +type UserStatus string + +// Webhook defines model for Webhook. +type Webhook struct { + ApiVersion *string `json:"api_version,omitempty"` + ConsecutiveFailures *int `json:"consecutive_failures,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + Description *string `json:"description,omitempty"` + Events *[]string `json:"events,omitempty"` + Id *string `json:"id,omitempty"` + LastDeliveryAt *time.Time `json:"last_delivery_at,omitempty"` + + // Scope 'platform' or an application_id + Scope *string `json:"scope,omitempty"` + Status *WebhookStatus `json:"status,omitempty"` + TestMode *bool `json:"test_mode,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Url *string `json:"url,omitempty"` +} + +// WebhookStatus defines model for Webhook.Status. +type WebhookStatus string + +// WebhookDelivery defines model for WebhookDelivery. +type WebhookDelivery struct { + Attempt *int `json:"attempt,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + DurationMs *int `json:"duration_ms,omitempty"` + Error *string `json:"error,omitempty"` + EventId *string `json:"event_id,omitempty"` + EventType *WebhookEvent `json:"event_type,omitempty"` + Id *string `json:"id,omitempty"` + NextRetryAt *time.Time `json:"next_retry_at,omitempty"` + ResponseStatus *int `json:"response_status,omitempty"` + Status *WebhookDeliveryStatus `json:"status,omitempty"` +} + +// WebhookDeliveryStatus defines model for WebhookDelivery.Status. +type WebhookDeliveryStatus string + +// WebhookEvent defines model for WebhookEvent. +type WebhookEvent string + +// WebhookEventEnvelope Body POSTed to a subscriber. Signed with Substratal-Signature: t=,v1=. +type WebhookEventEnvelope struct { + ApiVersion string `json:"api_version"` + ApplicationId *string `json:"application_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + Data map[string]interface{} `json:"data"` + + // Id wev_… — stable across retries; dedupe on it + Id string `json:"id"` + TestMode bool `json:"test_mode"` + Type WebhookEvent `json:"type"` +} + +// WebhookPage defines model for WebhookPage. +type WebhookPage struct { + Data []Webhook `json:"data"` + Page PageInfo `json:"page"` +} + +// WebhookWithSecret defines model for WebhookWithSecret. +type WebhookWithSecret struct { + ApiVersion *string `json:"api_version,omitempty"` + ConsecutiveFailures *int `json:"consecutive_failures,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + Description *string `json:"description,omitempty"` + Events *[]string `json:"events,omitempty"` + Id *string `json:"id,omitempty"` + LastDeliveryAt *time.Time `json:"last_delivery_at,omitempty"` + + // Scope 'platform' or an application_id + Scope *string `json:"scope,omitempty"` + SigningSecret *string `json:"signing_secret,omitempty"` + Status *WebhookWithSecretStatus `json:"status,omitempty"` + TestMode *bool `json:"test_mode,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Url *string `json:"url,omitempty"` +} + +// WebhookWithSecretStatus defines model for WebhookWithSecret.Status. +type WebhookWithSecretStatus string + +// AppId defines model for AppId. +type AppId = string + +// Cursor defines model for Cursor. +type Cursor = string + +// IdempotencyKey defines model for IdempotencyKey. +type IdempotencyKey = string + +// IdempotencyKeyOptional defines model for IdempotencyKeyOptional. +type IdempotencyKeyOptional = string + +// IfMatch defines model for IfMatch. +type IfMatch = string + +// Limit defines model for Limit. +type Limit = int + +// UserIdOrMe defines model for UserIdOrMe. +type UserIdOrMe = string + +// BadRequest defines model for BadRequest. +type BadRequest = Error + +// Conflict defines model for Conflict. +type Conflict = Error + +// Forbidden defines model for Forbidden. +type Forbidden = Error + +// NotFound defines model for NotFound. +type NotFound = Error + +// PaymentRequired defines model for PaymentRequired. +type PaymentRequired = Error + +// ServiceUnavailable defines model for ServiceUnavailable. +type ServiceUnavailable = Error + +// TooManyRequests defines model for TooManyRequests. +type TooManyRequests = Error + +// Unauthorized defines model for Unauthorized. +type Unauthorized = Error + +// Unprocessable defines model for Unprocessable. +type Unprocessable = Error + +// ApiKeysListParams defines parameters for ApiKeysList. +type ApiKeysListParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Scope *string `form:"scope,omitempty" json:"scope,omitempty"` + Mode *ApiKeysListParamsMode `form:"mode,omitempty" json:"mode,omitempty"` + IntendedUse *ApiKeysListParamsIntendedUse `form:"intended_use,omitempty" json:"intended_use,omitempty"` + + // IncludeInactive Include revoked and expired keys + IncludeInactive *bool `form:"include_inactive,omitempty" json:"include_inactive,omitempty"` +} + +// ApiKeysListParamsMode defines parameters for ApiKeysList. +type ApiKeysListParamsMode string + +// ApiKeysListParamsIntendedUse defines parameters for ApiKeysList. +type ApiKeysListParamsIntendedUse string + +// ApiKeysUpdateJSONBody defines parameters for ApiKeysUpdate. +type ApiKeysUpdateJSONBody struct { + Name *string `json:"name,omitempty"` + Permissions *[]string `json:"permissions,omitempty"` + RestrictDestructive *bool `json:"restrict_destructive,omitempty"` +} + +// ApplicationsListParams defines parameters for ApplicationsList. +type ApplicationsListParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Visibility *ApplicationVisibility `form:"visibility,omitempty" json:"visibility,omitempty"` + ReviewStatus *ApplicationReviewStatus `form:"review_status,omitempty" json:"review_status,omitempty"` + Owned *bool `form:"owned,omitempty" json:"owned,omitempty"` + TenantId *string `form:"tenant_id,omitempty" json:"tenant_id,omitempty"` + OwnerUserId *string `form:"owner_user_id,omitempty" json:"owner_user_id,omitempty"` + OwnerOrganizationId *string `form:"owner_organization_id,omitempty" json:"owner_organization_id,omitempty"` +} + +// ApplicationsCreateJSONBody defines parameters for ApplicationsCreate. +type ApplicationsCreateJSONBody struct { + AvailableAppRoles *[]string `json:"available_app_roles,omitempty"` + DefaultAppRole *string `json:"default_app_role,omitempty"` + Description *string `json:"description,omitempty"` + EmailFromName *string `json:"email_from_name,omitempty"` + IconUrl *string `json:"icon_url,omitempty"` + LaunchUrl string `json:"launch_url"` + Name string `json:"name"` + OwnerOrganizationId *string `json:"owner_organization_id,omitempty"` + OwnerUserId *string `json:"owner_user_id,omitempty"` + Permissions *[]ApplicationPermission `json:"permissions,omitempty"` + RedirectUris *[]string `json:"redirect_uris,omitempty"` + ReviewNotes *string `json:"review_notes,omitempty"` + ReviewStatus *ApplicationReviewStatus `json:"review_status,omitempty"` + + // SettingsSchema JSON Schema 2020-12 subset — see Domain Model → Settings → settings_schema rules + SettingsSchema *map[string]interface{} `json:"settings_schema,omitempty"` + + // Slug Create only; immutable. 'platform' is reserved (rejected with validation_failed, code unknown_value). The Application's id is app_. + Slug string `json:"slug"` + SupportUrl *string `json:"support_url,omitempty"` + Visibility *ApplicationVisibility `json:"visibility,omitempty"` +} + +// ApplicationsCreateParams defines parameters for ApplicationsCreate. +type ApplicationsCreateParams struct { + IdempotencyKey *IdempotencyKeyOptional `json:"Idempotency-Key,omitempty"` +} + +// ApplicationsUpdateParams defines parameters for ApplicationsUpdate. +type ApplicationsUpdateParams struct { + // IfMatch The ETag from a prior read. A mismatch returns 409 version_conflict. + IfMatch *IfMatch `json:"If-Match,omitempty"` +} + +// AuditListParams defines parameters for AuditList. +type AuditListParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + TargetUserId *string `form:"target_user_id,omitempty" json:"target_user_id,omitempty"` + TargetType *AuditTargetType `form:"target_type,omitempty" json:"target_type,omitempty"` + TargetId *string `form:"target_id,omitempty" json:"target_id,omitempty"` + ActorId *string `form:"actor_id,omitempty" json:"actor_id,omitempty"` + ActorType *AuditListParamsActorType `form:"actor_type,omitempty" json:"actor_type,omitempty"` + Action *[]AuditAction `form:"action,omitempty" json:"action,omitempty"` + ApplicationId *string `form:"application_id,omitempty" json:"application_id,omitempty"` + OrganizationId *string `form:"organization_id,omitempty" json:"organization_id,omitempty"` + TenantId *string `form:"tenant_id,omitempty" json:"tenant_id,omitempty"` + Since *time.Time `form:"since,omitempty" json:"since,omitempty"` + Until *time.Time `form:"until,omitempty" json:"until,omitempty"` +} + +// AuditListParamsActorType defines parameters for AuditList. +type AuditListParamsActorType string + +// AuthCreateAppTokenJSONBody defines parameters for AuthCreateAppToken. +type AuthCreateAppTokenJSONBody struct { + ApplicationId string `json:"application_id"` + + // OrganizationId Attribute org_id to this Organization's grant. + OrganizationId *string `json:"organization_id,omitempty"` +} + +// AuthVerifyEmailJSONBody defines parameters for AuthVerifyEmail. +type AuthVerifyEmailJSONBody struct { + Token string `json:"token"` +} + +// AuthResendEmailVerificationJSONBody defines parameters for AuthResendEmailVerification. +type AuthResendEmailVerificationJSONBody struct { + Email openapi_types.Email `json:"email"` + TestMode *bool `json:"test_mode,omitempty"` +} + +// AuthAcceptInvitationJSONBody defines parameters for AuthAcceptInvitation. +type AuthAcceptInvitationJSONBody struct { + DisplayName *string `json:"display_name,omitempty"` + Password string `json:"password"` + Token string `json:"token"` +} + +// AuthLoginJSONBody defines parameters for AuthLogin. +type AuthLoginJSONBody struct { + Email openapi_types.Email `json:"email"` + Password string `json:"password"` + + // TestMode Sign in the test-mode account with this email. + TestMode *bool `json:"test_mode,omitempty"` +} + +// AuthLogin200JSONResponseBody defines parameters for AuthLogin. +type AuthLogin200JSONResponseBody struct { + union json.RawMessage +} + +// AuthLogoutJSONBody defines parameters for AuthLogout. +type AuthLogoutJSONBody struct { + AllSessions *bool `json:"all_sessions,omitempty"` +} + +// AuthVerifyMfaJSONBody defines parameters for AuthVerifyMfa. +type AuthVerifyMfaJSONBody struct { + Code *string `json:"code,omitempty"` + MfaToken string `json:"mfa_token"` + RecoveryCode *string `json:"recovery_code,omitempty"` +} + +// AuthCreateAuthorizationCodeJSONBody defines parameters for AuthCreateAuthorizationCode. +type AuthCreateAuthorizationCodeJSONBody struct { + ApplicationId string `json:"application_id"` + CodeChallenge string `json:"code_challenge"` + CodeChallengeMethod AuthCreateAuthorizationCodeJSONBodyCodeChallengeMethod `json:"code_challenge_method"` + RedirectUri string `json:"redirect_uri"` + State *string `json:"state,omitempty"` +} + +// AuthCreateAuthorizationCodeJSONBodyCodeChallengeMethod defines parameters for AuthCreateAuthorizationCode. +type AuthCreateAuthorizationCodeJSONBodyCodeChallengeMethod string + +// AuthExchangeOAuthToken200JSONResponseBodyTokenType defines parameters for AuthExchangeOAuthToken. +type AuthExchangeOAuthToken200JSONResponseBodyTokenType string + +// AuthForgotPasswordJSONBody defines parameters for AuthForgotPassword. +type AuthForgotPasswordJSONBody struct { + Email openapi_types.Email `json:"email"` + TestMode *bool `json:"test_mode,omitempty"` +} + +// AuthResetPasswordJSONBody defines parameters for AuthResetPassword. +type AuthResetPasswordJSONBody struct { + NewPassword string `json:"new_password"` + Token string `json:"token"` +} + +// AuthSignupJSONBody defines parameters for AuthSignup. +type AuthSignupJSONBody struct { + // ApplicationId Informational: records which app the signup came from. Grants nothing. + ApplicationId *string `json:"application_id,omitempty"` + DisplayName *string `json:"display_name,omitempty"` + Email openapi_types.Email `json:"email"` + Password string `json:"password"` + + // TestMode Create a test-mode User. + TestMode *bool `json:"test_mode,omitempty"` +} + +// AuthSsoCallbackJSONBody defines parameters for AuthSsoCallback. +type AuthSsoCallbackJSONBody = map[string]interface{} + +// AuthRefreshTokenJSONBody defines parameters for AuthRefreshToken. +type AuthRefreshTokenJSONBody struct { + RefreshToken string `json:"refresh_token"` +} + +// EntitlementsListParams defines parameters for EntitlementsList. +type EntitlementsListParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + ApplicationId *string `form:"application_id,omitempty" json:"application_id,omitempty"` + Status *EntitlementStatus `form:"status,omitempty" json:"status,omitempty"` + UserId *string `form:"user_id,omitempty" json:"user_id,omitempty"` + OrganizationId *string `form:"organization_id,omitempty" json:"organization_id,omitempty"` + Source *EntitlementSource `form:"source,omitempty" json:"source,omitempty"` + OrderId *string `form:"order_id,omitempty" json:"order_id,omitempty"` + EndsBefore *time.Time `form:"ends_before,omitempty" json:"ends_before,omitempty"` + IncludeInactive *bool `form:"include_inactive,omitempty" json:"include_inactive,omitempty"` +} + +// EntitlementsUpdateJSONBody defines parameters for EntitlementsUpdate. +type EntitlementsUpdateJSONBody struct { + DisabledReason *string `json:"disabled_reason,omitempty"` + EndsAt *time.Time `json:"ends_at,omitempty"` + MemberOverrides *[]string `json:"member_overrides,omitempty"` + MemberScope *EntitlementMemberScope `json:"member_scope,omitempty"` + OrderId *string `json:"order_id,omitempty"` + Source *EntitlementsUpdateJSONBodySource `json:"source,omitempty"` + Status *EntitlementStatus `json:"status,omitempty"` +} + +// EntitlementsUpdateParams defines parameters for EntitlementsUpdate. +type EntitlementsUpdateParams struct { + // IfMatch The ETag from a prior read. A mismatch returns 409 version_conflict. + IfMatch *IfMatch `json:"If-Match,omitempty"` +} + +// EntitlementsUpdateJSONBodySource defines parameters for EntitlementsUpdate. +type EntitlementsUpdateJSONBodySource string + +// HealthCheck200JSONResponseBodyStatus defines parameters for HealthCheck. +type HealthCheck200JSONResponseBodyStatus string + +// OrganizationsListParams defines parameters for OrganizationsList. +type OrganizationsListParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Status *OrganizationsListParamsStatus `form:"status,omitempty" json:"status,omitempty"` + Q *string `form:"q,omitempty" json:"q,omitempty"` +} + +// OrganizationsListParamsStatus defines parameters for OrganizationsList. +type OrganizationsListParamsStatus string + +// OrganizationsCreateJSONBody defines parameters for OrganizationsCreate. +type OrganizationsCreateJSONBody struct { + Name string `json:"name"` +} + +// OrganizationsCreateParams defines parameters for OrganizationsCreate. +type OrganizationsCreateParams struct { + IdempotencyKey *IdempotencyKeyOptional `json:"Idempotency-Key,omitempty"` +} + +// OrganizationsUpdateJSONBody defines parameters for OrganizationsUpdate. +type OrganizationsUpdateJSONBody struct { + Name *string `json:"name,omitempty"` + Reason *string `json:"reason,omitempty"` + Status *OrganizationsUpdateJSONBodyStatus `json:"status,omitempty"` +} + +// OrganizationsUpdateParams defines parameters for OrganizationsUpdate. +type OrganizationsUpdateParams struct { + // IfMatch The ETag from a prior read. A mismatch returns 409 version_conflict. + IfMatch *IfMatch `json:"If-Match,omitempty"` +} + +// OrganizationsUpdateJSONBodyStatus defines parameters for OrganizationsUpdate. +type OrganizationsUpdateJSONBodyStatus string + +// OrganizationsListEntitlementsParams defines parameters for OrganizationsListEntitlements. +type OrganizationsListEntitlementsParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + ApplicationId *string `form:"application_id,omitempty" json:"application_id,omitempty"` + Status *EntitlementStatus `form:"status,omitempty" json:"status,omitempty"` + OrderId *string `form:"order_id,omitempty" json:"order_id,omitempty"` + EndsBefore *time.Time `form:"ends_before,omitempty" json:"ends_before,omitempty"` + IncludeInactive *bool `form:"include_inactive,omitempty" json:"include_inactive,omitempty"` +} + +// OrganizationsGrantEntitlementJSONBody defines parameters for OrganizationsGrantEntitlement. +type OrganizationsGrantEntitlementJSONBody struct { + ApplicationId string `json:"application_id"` + EndsAt *time.Time `json:"ends_at,omitempty"` + MemberOverrides *[]string `json:"member_overrides,omitempty"` + MemberScope *EntitlementMemberScope `json:"member_scope,omitempty"` + OrderId *string `json:"order_id,omitempty"` + StartsAt *time.Time `json:"starts_at,omitempty"` +} + +// OrganizationsGrantEntitlementParams defines parameters for OrganizationsGrantEntitlement. +type OrganizationsGrantEntitlementParams struct { + // IdempotencyKey Client-generated, 1–255 characters; a UUID v4 is recommended. Remembered 24 hours per caller. + IdempotencyKey IdempotencyKey `json:"Idempotency-Key"` +} + +// OrganizationsListMembersParams defines parameters for OrganizationsListMembers. +type OrganizationsListMembersParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Role *OrganizationMemberRole `form:"role,omitempty" json:"role,omitempty"` + MembershipStatus *OrganizationMembershipStatus `form:"membership_status,omitempty" json:"membership_status,omitempty"` + Q *string `form:"q,omitempty" json:"q,omitempty"` +} + +// OrganizationsAddMemberJSONBody defines parameters for OrganizationsAddMember. +type OrganizationsAddMemberJSONBody struct { + Email *openapi_types.Email `json:"email,omitempty"` + Role *OrganizationMemberRole `json:"role,omitempty"` + UserId *string `json:"user_id,omitempty"` + union json.RawMessage +} + +// OrganizationsAddMemberJSONBody0 defines parameters for OrganizationsAddMember. +type OrganizationsAddMemberJSONBody0 = interface{} + +// OrganizationsAddMemberJSONBody1 defines parameters for OrganizationsAddMember. +type OrganizationsAddMemberJSONBody1 = interface{} + +// OrganizationsUpdateMemberJSONBody defines parameters for OrganizationsUpdateMember. +type OrganizationsUpdateMemberJSONBody struct { + Role OrganizationMemberRole `json:"role"` +} + +// PermissionsListParams defines parameters for PermissionsList. +type PermissionsListParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + + // Scope 'platform' or an application_id + Scope *string `form:"scope,omitempty" json:"scope,omitempty"` +} + +// RolesListParams defines parameters for RolesList. +type RolesListParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + + // Scope 'platform' or an application_id + Scope *string `form:"scope,omitempty" json:"scope,omitempty"` +} + +// RolesUpdateJSONBody defines parameters for RolesUpdate. +type RolesUpdateJSONBody struct { + Description *string `json:"description,omitempty"` + Permissions *[]string `json:"permissions,omitempty"` +} + +// RolesUpdateParams defines parameters for RolesUpdate. +type RolesUpdateParams struct { + // IfMatch The ETag from a prior read. A mismatch returns 409 version_conflict. + IfMatch *IfMatch `json:"If-Match,omitempty"` +} + +// TenantsListParams defines parameters for TenantsList. +type TenantsListParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Plan *TenantPlan `form:"plan,omitempty" json:"plan,omitempty"` + Tier *TenantTier `form:"tier,omitempty" json:"tier,omitempty"` + Status *TenantStatus `form:"status,omitempty" json:"status,omitempty"` + Restricted *bool `form:"restricted,omitempty" json:"restricted,omitempty"` + OwnerUserId *string `form:"owner_user_id,omitempty" json:"owner_user_id,omitempty"` + OwnerOrganizationId *string `form:"owner_organization_id,omitempty" json:"owner_organization_id,omitempty"` +} + +// BillingCreateCheckoutSessionJSONBody defines parameters for BillingCreateCheckoutSession. +type BillingCreateCheckoutSessionJSONBody struct { + CancelUrl string `json:"cancel_url"` + Plan BillingCreateCheckoutSessionJSONBodyPlan `json:"plan"` + SuccessUrl string `json:"success_url"` +} + +// BillingCreateCheckoutSessionParams defines parameters for BillingCreateCheckoutSession. +type BillingCreateCheckoutSessionParams struct { + // IdempotencyKey Client-generated, 1–255 characters; a UUID v4 is recommended. Remembered 24 hours per caller. + IdempotencyKey IdempotencyKey `json:"Idempotency-Key"` +} + +// BillingCreateCheckoutSessionJSONBodyPlan defines parameters for BillingCreateCheckoutSession. +type BillingCreateCheckoutSessionJSONBodyPlan string + +// BillingCreatePortalSessionJSONBody defines parameters for BillingCreatePortalSession. +type BillingCreatePortalSessionJSONBody struct { + ReturnUrl string `json:"return_url"` +} + +// TenantsListTierChangeRequestsParams defines parameters for TenantsListTierChangeRequests. +type TenantsListTierChangeRequestsParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` +} + +// TenantsRequestTierChangeJSONBody defines parameters for TenantsRequestTierChange. +type TenantsRequestTierChangeJSONBody struct { + Reason string `json:"reason"` + RequestedRegion *Region `json:"requested_region,omitempty"` + RequestedTier TenantsRequestTierChangeJSONBodyRequestedTier `json:"requested_tier"` +} + +// TenantsRequestTierChangeJSONBodyRequestedTier defines parameters for TenantsRequestTierChange. +type TenantsRequestTierChangeJSONBodyRequestedTier string + +// TenantsUpdateTierChangeRequestJSONBody defines parameters for TenantsUpdateTierChangeRequest. +type TenantsUpdateTierChangeRequestJSONBody struct { + Notes *string `json:"notes,omitempty"` + ScheduledFor *time.Time `json:"scheduled_for,omitempty"` + Status TenantsUpdateTierChangeRequestJSONBodyStatus `json:"status"` +} + +// TenantsUpdateTierChangeRequestParams defines parameters for TenantsUpdateTierChangeRequest. +type TenantsUpdateTierChangeRequestParams struct { + // IfMatch The ETag from a prior read. A mismatch returns 409 version_conflict. + IfMatch *IfMatch `json:"If-Match,omitempty"` +} + +// TenantsUpdateTierChangeRequestJSONBodyStatus defines parameters for TenantsUpdateTierChangeRequest. +type TenantsUpdateTierChangeRequestJSONBodyStatus string + +// UsersListParams defines parameters for UsersList. +type UsersListParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + + // Status deleted is allowed for users.manage only + Status *UsersListParamsStatus `form:"status,omitempty" json:"status,omitempty"` + Email *openapi_types.Email `form:"email,omitempty" json:"email,omitempty"` + Q *string `form:"q,omitempty" json:"q,omitempty"` + ApplicationId *string `form:"application_id,omitempty" json:"application_id,omitempty"` + OrganizationId *string `form:"organization_id,omitempty" json:"organization_id,omitempty"` + CreatedAfter *time.Time `form:"created_after,omitempty" json:"created_after,omitempty"` + CreatedBefore *time.Time `form:"created_before,omitempty" json:"created_before,omitempty"` +} + +// UsersListParamsStatus defines parameters for UsersList. +type UsersListParamsStatus string + +// UsersCreateJSONBody defines parameters for UsersCreate. +type UsersCreateJSONBody struct { + DisplayName *string `json:"display_name,omitempty"` + Email openapi_types.Email `json:"email"` + SendInvitation *bool `json:"send_invitation,omitempty"` + Status *UsersCreateJSONBodyStatus `json:"status,omitempty"` +} + +// UsersCreateParams defines parameters for UsersCreate. +type UsersCreateParams struct { + IdempotencyKey *IdempotencyKeyOptional `json:"Idempotency-Key,omitempty"` +} + +// UsersCreateJSONBodyStatus defines parameters for UsersCreate. +type UsersCreateJSONBodyStatus string + +// UsersUpdateJSONBody defines parameters for UsersUpdate. +type UsersUpdateJSONBody struct { + Email *openapi_types.Email `json:"email,omitempty"` + + // Status users.manage only. active on a deleted User restores it. + Status *UsersUpdateJSONBodyStatus `json:"status,omitempty"` +} + +// UsersUpdateParams defines parameters for UsersUpdate. +type UsersUpdateParams struct { + // IfMatch The ETag from a prior read. A mismatch returns 409 version_conflict. + IfMatch *IfMatch `json:"If-Match,omitempty"` +} + +// UsersUpdateJSONBodyStatus defines parameters for UsersUpdate. +type UsersUpdateJSONBodyStatus string + +// AppProfilesUpdateJSONBody defines parameters for AppProfilesUpdate. +type AppProfilesUpdateJSONBody struct { + Custom *map[string]interface{} `json:"custom,omitempty"` + DisplayHandle *string `json:"display_handle,omitempty"` +} + +// AppProfilesUpdateParams defines parameters for AppProfilesUpdate. +type AppProfilesUpdateParams struct { + // IfMatch The ETag from a prior read. A mismatch returns 409 version_conflict. + IfMatch *IfMatch `json:"If-Match,omitempty"` +} + +// AppSettingsUpdateJSONBody defines parameters for AppSettingsUpdate. +type AppSettingsUpdateJSONBody struct { + Overrides map[string]interface{} `json:"overrides"` +} + +// AppSettingsUpdateParams defines parameters for AppSettingsUpdate. +type AppSettingsUpdateParams struct { + // IfMatch The ETag from a prior read. A mismatch returns 409 version_conflict. + IfMatch *IfMatch `json:"If-Match,omitempty"` +} + +// EntitlementsListForUserParams defines parameters for EntitlementsListForUser. +type EntitlementsListForUserParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + ApplicationId *string `form:"application_id,omitempty" json:"application_id,omitempty"` + Status *EntitlementStatus `form:"status,omitempty" json:"status,omitempty"` + Source *EntitlementSource `form:"source,omitempty" json:"source,omitempty"` + IncludeInactive *bool `form:"include_inactive,omitempty" json:"include_inactive,omitempty"` +} + +// EntitlementsGrantJSONBody defines parameters for EntitlementsGrant. +type EntitlementsGrantJSONBody struct { + ApplicationId string `json:"application_id"` + EndsAt *time.Time `json:"ends_at,omitempty"` + OrderId *string `json:"order_id,omitempty"` + Source EntitlementsGrantJSONBodySource `json:"source"` + StartsAt *time.Time `json:"starts_at,omitempty"` +} + +// EntitlementsGrantParams defines parameters for EntitlementsGrant. +type EntitlementsGrantParams struct { + // IdempotencyKey Client-generated, 1–255 characters; a UUID v4 is recommended. Remembered 24 hours per caller. + IdempotencyKey IdempotencyKey `json:"Idempotency-Key"` +} + +// EntitlementsGrantJSONBodySource defines parameters for EntitlementsGrant. +type EntitlementsGrantJSONBodySource string + +// UsersRequestErasureJSONBody defines parameters for UsersRequestErasure. +type UsersRequestErasureJSONBody struct { + Reason *string `json:"reason,omitempty"` +} + +// AuthConfirmMfaTotpJSONBody defines parameters for AuthConfirmMfaTotp. +type AuthConfirmMfaTotpJSONBody struct { + Code string `json:"code"` +} + +// AuthDisableMfaTotpJSONBody defines parameters for AuthDisableMfaTotp. +type AuthDisableMfaTotpJSONBody struct { + // Code A current TOTP code + Code *string `json:"code,omitempty"` + RecoveryCode *string `json:"recovery_code,omitempty"` +} + +// AuthChangePasswordJSONBody defines parameters for AuthChangePassword. +type AuthChangePasswordJSONBody struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +// ProfilesUpdateParams defines parameters for ProfilesUpdate. +type ProfilesUpdateParams struct { + // IfMatch The ETag from a prior read. A mismatch returns 409 version_conflict. + IfMatch *IfMatch `json:"If-Match,omitempty"` +} + +// RolesListForUserParams defines parameters for RolesListForUser. +type RolesListForUserParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + ApplicationId *string `form:"application_id,omitempty" json:"application_id,omitempty"` +} + +// AuthListSessionsParams defines parameters for AuthListSessions. +type AuthListSessionsParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` +} + +// SettingsUpdateParams defines parameters for SettingsUpdate. +type SettingsUpdateParams struct { + // IfMatch The ETag from a prior read. A mismatch returns 409 version_conflict. + IfMatch *IfMatch `json:"If-Match,omitempty"` +} + +// UsersSuspendJSONBody defines parameters for UsersSuspend. +type UsersSuspendJSONBody struct { + Reason *string `json:"reason,omitempty"` +} + +// WebhooksListParams defines parameters for WebhooksList. +type WebhooksListParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + ApplicationId *string `form:"application_id,omitempty" json:"application_id,omitempty"` +} + +// WebhooksCreateJSONBody defines parameters for WebhooksCreate. +type WebhooksCreateJSONBody struct { + Description *string `json:"description,omitempty"` + Events []WebhooksCreateJSONBody_Events_Item `json:"events"` + + // Scope An application_id, or 'platform' (webhooks.manage only). Required for an owner's User token or a webhooks.manage caller; optional for an app key, where it defaults to (and must equal) the key's own Application. + Scope *string `json:"scope,omitempty"` + Url string `json:"url"` +} + +// WebhooksCreateJSONBodyEvents1 defines parameters for WebhooksCreate. +type WebhooksCreateJSONBodyEvents1 string + +// WebhooksCreateJSONBody_Events_Item defines parameters for WebhooksCreate. +type WebhooksCreateJSONBody_Events_Item struct { + union json.RawMessage +} + +// WebhooksUpdateJSONBody defines parameters for WebhooksUpdate. +type WebhooksUpdateJSONBody struct { + ApiVersion *string `json:"api_version,omitempty"` + Description *string `json:"description,omitempty"` + Events *[]string `json:"events,omitempty"` + Status *WebhooksUpdateJSONBodyStatus `json:"status,omitempty"` + Url *string `json:"url,omitempty"` +} + +// WebhooksUpdateJSONBodyStatus defines parameters for WebhooksUpdate. +type WebhooksUpdateJSONBodyStatus string + +// WebhooksListDeliveriesParams defines parameters for WebhooksListDeliveries. +type WebhooksListDeliveriesParams struct { + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + EventId *string `form:"event_id,omitempty" json:"event_id,omitempty"` + Status *WebhooksListDeliveriesParamsStatus `form:"status,omitempty" json:"status,omitempty"` + Since *time.Time `form:"since,omitempty" json:"since,omitempty"` +} + +// WebhooksListDeliveriesParamsStatus defines parameters for WebhooksListDeliveries. +type WebhooksListDeliveriesParamsStatus string + +// ApiKeysCreateJSONRequestBody defines body for ApiKeysCreate for application/json ContentType. +type ApiKeysCreateJSONRequestBody = ApiKeyWrite + +// ApiKeysUpdateJSONRequestBody defines body for ApiKeysUpdate for application/json ContentType. +type ApiKeysUpdateJSONRequestBody ApiKeysUpdateJSONBody + +// ApplicationsCreateJSONRequestBody defines body for ApplicationsCreate for application/json ContentType. +type ApplicationsCreateJSONRequestBody ApplicationsCreateJSONBody + +// ApplicationsUpdateJSONRequestBody defines body for ApplicationsUpdate for application/json ContentType. +type ApplicationsUpdateJSONRequestBody = ApplicationWrite + +// AuthCreateAppTokenJSONRequestBody defines body for AuthCreateAppToken for application/json ContentType. +type AuthCreateAppTokenJSONRequestBody AuthCreateAppTokenJSONBody + +// AuthVerifyEmailJSONRequestBody defines body for AuthVerifyEmail for application/json ContentType. +type AuthVerifyEmailJSONRequestBody AuthVerifyEmailJSONBody + +// AuthResendEmailVerificationJSONRequestBody defines body for AuthResendEmailVerification for application/json ContentType. +type AuthResendEmailVerificationJSONRequestBody AuthResendEmailVerificationJSONBody + +// AuthAcceptInvitationJSONRequestBody defines body for AuthAcceptInvitation for application/json ContentType. +type AuthAcceptInvitationJSONRequestBody AuthAcceptInvitationJSONBody + +// AuthLoginJSONRequestBody defines body for AuthLogin for application/json ContentType. +type AuthLoginJSONRequestBody AuthLoginJSONBody + +// AuthLogoutJSONRequestBody defines body for AuthLogout for application/json ContentType. +type AuthLogoutJSONRequestBody AuthLogoutJSONBody + +// AuthVerifyMfaJSONRequestBody defines body for AuthVerifyMfa for application/json ContentType. +type AuthVerifyMfaJSONRequestBody AuthVerifyMfaJSONBody + +// AuthCreateAuthorizationCodeJSONRequestBody defines body for AuthCreateAuthorizationCode for application/json ContentType. +type AuthCreateAuthorizationCodeJSONRequestBody AuthCreateAuthorizationCodeJSONBody + +// AuthExchangeOAuthTokenJSONRequestBody defines body for AuthExchangeOAuthToken for application/json ContentType. +type AuthExchangeOAuthTokenJSONRequestBody = OAuthTokenRequest + +// AuthExchangeOAuthTokenFormdataRequestBody defines body for AuthExchangeOAuthToken for application/x-www-form-urlencoded ContentType. +type AuthExchangeOAuthTokenFormdataRequestBody = OAuthTokenRequest + +// AuthForgotPasswordJSONRequestBody defines body for AuthForgotPassword for application/json ContentType. +type AuthForgotPasswordJSONRequestBody AuthForgotPasswordJSONBody + +// AuthResetPasswordJSONRequestBody defines body for AuthResetPassword for application/json ContentType. +type AuthResetPasswordJSONRequestBody AuthResetPasswordJSONBody + +// AuthSignupJSONRequestBody defines body for AuthSignup for application/json ContentType. +type AuthSignupJSONRequestBody AuthSignupJSONBody + +// AuthSsoCallbackJSONRequestBody defines body for AuthSsoCallback for application/json ContentType. +type AuthSsoCallbackJSONRequestBody = AuthSsoCallbackJSONBody + +// AuthRefreshTokenJSONRequestBody defines body for AuthRefreshToken for application/json ContentType. +type AuthRefreshTokenJSONRequestBody AuthRefreshTokenJSONBody + +// EntitlementsUpdateJSONRequestBody defines body for EntitlementsUpdate for application/json ContentType. +type EntitlementsUpdateJSONRequestBody EntitlementsUpdateJSONBody + +// OrganizationsCreateJSONRequestBody defines body for OrganizationsCreate for application/json ContentType. +type OrganizationsCreateJSONRequestBody OrganizationsCreateJSONBody + +// OrganizationsUpdateJSONRequestBody defines body for OrganizationsUpdate for application/json ContentType. +type OrganizationsUpdateJSONRequestBody OrganizationsUpdateJSONBody + +// OrganizationsGrantEntitlementJSONRequestBody defines body for OrganizationsGrantEntitlement for application/json ContentType. +type OrganizationsGrantEntitlementJSONRequestBody OrganizationsGrantEntitlementJSONBody + +// OrganizationsAddMemberJSONRequestBody defines body for OrganizationsAddMember for application/json ContentType. +type OrganizationsAddMemberJSONRequestBody OrganizationsAddMemberJSONBody + +// OrganizationsUpdateMemberJSONRequestBody defines body for OrganizationsUpdateMember for application/json ContentType. +type OrganizationsUpdateMemberJSONRequestBody OrganizationsUpdateMemberJSONBody + +// RolesCreateJSONRequestBody defines body for RolesCreate for application/json ContentType. +type RolesCreateJSONRequestBody = RoleCreate + +// RolesUpdateJSONRequestBody defines body for RolesUpdate for application/json ContentType. +type RolesUpdateJSONRequestBody RolesUpdateJSONBody + +// BillingCreateCheckoutSessionJSONRequestBody defines body for BillingCreateCheckoutSession for application/json ContentType. +type BillingCreateCheckoutSessionJSONRequestBody BillingCreateCheckoutSessionJSONBody + +// BillingCreatePortalSessionJSONRequestBody defines body for BillingCreatePortalSession for application/json ContentType. +type BillingCreatePortalSessionJSONRequestBody BillingCreatePortalSessionJSONBody + +// TenantsRequestTierChangeJSONRequestBody defines body for TenantsRequestTierChange for application/json ContentType. +type TenantsRequestTierChangeJSONRequestBody TenantsRequestTierChangeJSONBody + +// TenantsUpdateTierChangeRequestJSONRequestBody defines body for TenantsUpdateTierChangeRequest for application/json ContentType. +type TenantsUpdateTierChangeRequestJSONRequestBody TenantsUpdateTierChangeRequestJSONBody + +// UsersCreateJSONRequestBody defines body for UsersCreate for application/json ContentType. +type UsersCreateJSONRequestBody UsersCreateJSONBody + +// UsersUpdateJSONRequestBody defines body for UsersUpdate for application/json ContentType. +type UsersUpdateJSONRequestBody UsersUpdateJSONBody + +// AppProfilesUpdateJSONRequestBody defines body for AppProfilesUpdate for application/json ContentType. +type AppProfilesUpdateJSONRequestBody AppProfilesUpdateJSONBody + +// AppSettingsUpdateJSONRequestBody defines body for AppSettingsUpdate for application/json ContentType. +type AppSettingsUpdateJSONRequestBody AppSettingsUpdateJSONBody + +// EntitlementsGrantJSONRequestBody defines body for EntitlementsGrant for application/json ContentType. +type EntitlementsGrantJSONRequestBody EntitlementsGrantJSONBody + +// UsersRequestErasureJSONRequestBody defines body for UsersRequestErasure for application/json ContentType. +type UsersRequestErasureJSONRequestBody UsersRequestErasureJSONBody + +// AuthConfirmMfaTotpJSONRequestBody defines body for AuthConfirmMfaTotp for application/json ContentType. +type AuthConfirmMfaTotpJSONRequestBody AuthConfirmMfaTotpJSONBody + +// AuthDisableMfaTotpJSONRequestBody defines body for AuthDisableMfaTotp for application/json ContentType. +type AuthDisableMfaTotpJSONRequestBody AuthDisableMfaTotpJSONBody + +// AuthChangePasswordJSONRequestBody defines body for AuthChangePassword for application/json ContentType. +type AuthChangePasswordJSONRequestBody AuthChangePasswordJSONBody + +// ProfilesUpdateJSONRequestBody defines body for ProfilesUpdate for application/json ContentType. +type ProfilesUpdateJSONRequestBody = ProfileWrite + +// SettingsUpdateJSONRequestBody defines body for SettingsUpdate for application/json ContentType. +type SettingsUpdateJSONRequestBody = SettingsWrite + +// UsersSuspendJSONRequestBody defines body for UsersSuspend for application/json ContentType. +type UsersSuspendJSONRequestBody UsersSuspendJSONBody + +// WebhooksCreateJSONRequestBody defines body for WebhooksCreate for application/json ContentType. +type WebhooksCreateJSONRequestBody WebhooksCreateJSONBody + +// WebhooksUpdateJSONRequestBody defines body for WebhooksUpdate for application/json ContentType. +type WebhooksUpdateJSONRequestBody WebhooksUpdateJSONBody + +// Getter for additional properties for Error_Error_Details. Returns the specified +// element and whether it was found +func (a Error_Error_Details) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for Error_Error_Details +func (a *Error_Error_Details) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for Error_Error_Details to handle AdditionalProperties +func (a *Error_Error_Details) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["fields"]; found { + err = json.Unmarshal(raw, &a.Fields) + if err != nil { + return fmt.Errorf("error reading 'fields': %w", err) + } + delete(object, "fields") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for Error_Error_Details to handle AdditionalProperties +func (a Error_Error_Details) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + if a.Fields != nil { + object["fields"], err = json.Marshal(a.Fields) + if err != nil { + return nil, fmt.Errorf("error marshaling 'fields': %w", err) + } + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + +// AsAuthSession returns the union data inside the AuthLogin200JSONResponseBody as a AuthSession +func (t AuthLogin200JSONResponseBody) AsAuthSession() (AuthSession, error) { + var body AuthSession + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromAuthSession overwrites any union data inside the AuthLogin200JSONResponseBody as the provided AuthSession +func (t *AuthLogin200JSONResponseBody) FromAuthSession(v AuthSession) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeAuthSession performs a merge with any union data inside the AuthLogin200JSONResponseBody, using the provided AuthSession +func (t *AuthLogin200JSONResponseBody) MergeAuthSession(v AuthSession) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsMfaChallenge returns the union data inside the AuthLogin200JSONResponseBody as a MfaChallenge +func (t AuthLogin200JSONResponseBody) AsMfaChallenge() (MfaChallenge, error) { + var body MfaChallenge + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromMfaChallenge overwrites any union data inside the AuthLogin200JSONResponseBody as the provided MfaChallenge +func (t *AuthLogin200JSONResponseBody) FromMfaChallenge(v MfaChallenge) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeMfaChallenge performs a merge with any union data inside the AuthLogin200JSONResponseBody, using the provided MfaChallenge +func (t *AuthLogin200JSONResponseBody) MergeMfaChallenge(v MfaChallenge) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t AuthLogin200JSONResponseBody) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *AuthLogin200JSONResponseBody) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsOrganizationsAddMemberJSONBody0 returns the union data inside the OrganizationsAddMemberJSONBody as a OrganizationsAddMemberJSONBody0 +func (t OrganizationsAddMemberJSONBody) AsOrganizationsAddMemberJSONBody0() (OrganizationsAddMemberJSONBody0, error) { + var body OrganizationsAddMemberJSONBody0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromOrganizationsAddMemberJSONBody0 overwrites any union data inside the OrganizationsAddMemberJSONBody as the provided OrganizationsAddMemberJSONBody0 +func (t *OrganizationsAddMemberJSONBody) FromOrganizationsAddMemberJSONBody0(v OrganizationsAddMemberJSONBody0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeOrganizationsAddMemberJSONBody0 performs a merge with any union data inside the OrganizationsAddMemberJSONBody, using the provided OrganizationsAddMemberJSONBody0 +func (t *OrganizationsAddMemberJSONBody) MergeOrganizationsAddMemberJSONBody0(v OrganizationsAddMemberJSONBody0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsOrganizationsAddMemberJSONBody1 returns the union data inside the OrganizationsAddMemberJSONBody as a OrganizationsAddMemberJSONBody1 +func (t OrganizationsAddMemberJSONBody) AsOrganizationsAddMemberJSONBody1() (OrganizationsAddMemberJSONBody1, error) { + var body OrganizationsAddMemberJSONBody1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromOrganizationsAddMemberJSONBody1 overwrites any union data inside the OrganizationsAddMemberJSONBody as the provided OrganizationsAddMemberJSONBody1 +func (t *OrganizationsAddMemberJSONBody) FromOrganizationsAddMemberJSONBody1(v OrganizationsAddMemberJSONBody1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeOrganizationsAddMemberJSONBody1 performs a merge with any union data inside the OrganizationsAddMemberJSONBody, using the provided OrganizationsAddMemberJSONBody1 +func (t *OrganizationsAddMemberJSONBody) MergeOrganizationsAddMemberJSONBody1(v OrganizationsAddMemberJSONBody1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t OrganizationsAddMemberJSONBody) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + if err != nil { + return nil, err + } + object := make(map[string]json.RawMessage) + if t.union != nil { + err = json.Unmarshal(b, &object) + if err != nil { + return nil, err + } + } + + if t.Email != nil { + object["email"], err = json.Marshal(t.Email) + if err != nil { + return nil, fmt.Errorf("error marshaling 'email': %w", err) + } + } + + if t.Role != nil { + object["role"], err = json.Marshal(t.Role) + if err != nil { + return nil, fmt.Errorf("error marshaling 'role': %w", err) + } + } + + if t.UserId != nil { + object["user_id"], err = json.Marshal(t.UserId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'user_id': %w", err) + } + } + b, err = json.Marshal(object) + return b, err +} + +func (t *OrganizationsAddMemberJSONBody) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + if err != nil { + return err + } + object := make(map[string]json.RawMessage) + err = json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["email"]; found { + err = json.Unmarshal(raw, &t.Email) + if err != nil { + return fmt.Errorf("error reading 'email': %w", err) + } + } + + if raw, found := object["role"]; found { + err = json.Unmarshal(raw, &t.Role) + if err != nil { + return fmt.Errorf("error reading 'role': %w", err) + } + } + + if raw, found := object["user_id"]; found { + err = json.Unmarshal(raw, &t.UserId) + if err != nil { + return fmt.Errorf("error reading 'user_id': %w", err) + } + } + + return err +} + +// AsWebhookEvent returns the union data inside the WebhooksCreateJSONBody_Events_Item as a WebhookEvent +func (t WebhooksCreateJSONBody_Events_Item) AsWebhookEvent() (WebhookEvent, error) { + var body WebhookEvent + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromWebhookEvent overwrites any union data inside the WebhooksCreateJSONBody_Events_Item as the provided WebhookEvent +func (t *WebhooksCreateJSONBody_Events_Item) FromWebhookEvent(v WebhookEvent) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeWebhookEvent performs a merge with any union data inside the WebhooksCreateJSONBody_Events_Item, using the provided WebhookEvent +func (t *WebhooksCreateJSONBody_Events_Item) MergeWebhookEvent(v WebhookEvent) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsWebhooksCreateJSONBodyEvents1 returns the union data inside the WebhooksCreateJSONBody_Events_Item as a WebhooksCreateJSONBodyEvents1 +func (t WebhooksCreateJSONBody_Events_Item) AsWebhooksCreateJSONBodyEvents1() (WebhooksCreateJSONBodyEvents1, error) { + var body WebhooksCreateJSONBodyEvents1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromWebhooksCreateJSONBodyEvents1 overwrites any union data inside the WebhooksCreateJSONBody_Events_Item as the provided WebhooksCreateJSONBodyEvents1 +func (t *WebhooksCreateJSONBody_Events_Item) FromWebhooksCreateJSONBodyEvents1(v WebhooksCreateJSONBodyEvents1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeWebhooksCreateJSONBodyEvents1 performs a merge with any union data inside the WebhooksCreateJSONBody_Events_Item, using the provided WebhooksCreateJSONBodyEvents1 +func (t *WebhooksCreateJSONBody_Events_Item) MergeWebhooksCreateJSONBodyEvents1(v WebhooksCreateJSONBodyEvents1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t WebhooksCreateJSONBody_Events_Item) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *WebhooksCreateJSONBody_Events_Item) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // ApiKeysList List keys the caller may manage + // (GET /api-keys) + ApiKeysList(w http.ResponseWriter, r *http.Request, params ApiKeysListParams) + // ApiKeysCreate Create a key (api_keys.manage, or the Application's owner for an app-scoped key) + // (POST /api-keys) + ApiKeysCreate(w http.ResponseWriter, r *http.Request) + // ApiKeysDelete Revoke permanently + // (DELETE /api-keys/{id}) + ApiKeysDelete(w http.ResponseWriter, r *http.Request, id string) + // ApiKeysGet Fetch one key (never the secret) + // (GET /api-keys/{id}) + ApiKeysGet(w http.ResponseWriter, r *http.Request, id string) + // ApiKeysUpdate Change name, permissions, or restrict_destructive + // (PATCH /api-keys/{id}) + ApiKeysUpdate(w http.ResponseWriter, r *http.Request, id string) + // ApiKeysRotate Issue a new secret, invalidating the old one immediately (no overlap) + // (POST /api-keys/{id}/rotate) + ApiKeysRotate(w http.ResponseWriter, r *http.Request, id string) + // ApplicationsList List the catalog (filtered by visibility rules) + // (GET /applications) + ApplicationsList(w http.ResponseWriter, r *http.Request, params ApplicationsListParams) + // ApplicationsCreate Register an Application (applications.manage) + // (POST /applications) + ApplicationsCreate(w http.ResponseWriter, r *http.Request, params ApplicationsCreateParams) + // ApplicationsGet Fetch one Application + // (GET /applications/{id}) + ApplicationsGet(w http.ResponseWriter, r *http.Request, id string) + // ApplicationsUpdate Update a catalog entry (owner — configuration; applications.manage — anything) + // (PATCH /applications/{id}) + ApplicationsUpdate(w http.ResponseWriter, r *http.Request, id string, params ApplicationsUpdateParams) + // AuditList Query the audit log (hot window only) + // (GET /audit-events) + AuditList(w http.ResponseWriter, r *http.Request, params AuditListParams) + // AuditGet Fetch one Audit Event + // (GET /audit-events/{id}) + AuditGet(w http.ResponseWriter, r *http.Request, id string) + // AuthCreateAppToken Mint a per-app JWT from a platform session (embedded login) + // (POST /auth/app-tokens) + AuthCreateAppToken(w http.ResponseWriter, r *http.Request) + // AuthVerifyEmail Confirm an email address from an emailed token + // (POST /auth/email/verify) + AuthVerifyEmail(w http.ResponseWriter, r *http.Request) + // AuthResendEmailVerification Re-send a verification email (always 202) + // (POST /auth/email/verify/resend) + AuthResendEmailVerification(w http.ResponseWriter, r *http.Request) + // AuthAcceptInvitation Set a password on an invited account, activate it, and sign in + // (POST /auth/invitations/accept) + AuthAcceptInvitation(w http.ResponseWriter, r *http.Request) + // AuthLogin Exchange email and password for a session (or an MFA challenge) + // (POST /auth/login) + AuthLogin(w http.ResponseWriter, r *http.Request) + // AuthLogout Revoke the current session, or every session + // (POST /auth/logout) + AuthLogout(w http.ResponseWriter, r *http.Request) + // AuthVerifyMfa Complete a login that returned an MFA challenge + // (POST /auth/mfa/verify) + AuthVerifyMfa(w http.ResponseWriter, r *http.Request) + // AuthCreateAuthorizationCode Mint a one-time authorization code for a hosted launch (PKCE) + // (POST /auth/oauth/authorization-codes) + AuthCreateAuthorizationCode(w http.ResponseWriter, r *http.Request) + // AuthExchangeOAuthToken Exchange an authorization code or app refresh token for an app token + // (POST /auth/oauth/token) + AuthExchangeOAuthToken(w http.ResponseWriter, r *http.Request) + // AuthForgotPassword Send a password-reset email (always 202) + // (POST /auth/password/forgot) + AuthForgotPassword(w http.ResponseWriter, r *http.Request) + // AuthResetPassword Set a new password from a reset token (revokes every session) + // (POST /auth/password/reset) + AuthResetPassword(w http.ResponseWriter, r *http.Request) + // AuthSignup Create an account with email and password + // (POST /auth/signup) + AuthSignup(w http.ResponseWriter, r *http.Request) + // AuthSsoCallback Complete an SSO login (reserved — 501 until the broker integration ships) + // (POST /auth/sso/{provider}/callback) + AuthSsoCallback(w http.ResponseWriter, r *http.Request, provider string) + // AuthRefreshToken Rotate a platform refresh token for a new access token + // (POST /auth/token/refresh) + AuthRefreshToken(w http.ResponseWriter, r *http.Request) + // EntitlementsList List entitlements across users (entitlements.manage or the Application's owner; app keys and owners confined) + // (GET /entitlements) + EntitlementsList(w http.ResponseWriter, r *http.Request, params EntitlementsListParams) + // EntitlementsDelete Hard-remove a grant made in error (no order_id, under 24 hours old) + // (DELETE /entitlements/{id}) + EntitlementsDelete(w http.ResponseWriter, r *http.Request, id string) + // EntitlementsGet Fetch one entitlement + // (GET /entitlements/{id}) + EntitlementsGet(w http.ResponseWriter, r *http.Request, id string) + // EntitlementsUpdate The toggle: change status, term, source (trial→purchase), order_id, or an org grant's member scope + // (PATCH /entitlements/{id}) + EntitlementsUpdate(w http.ResponseWriter, r *http.Request, id string, params EntitlementsUpdateParams) + // HealthCheck Health check + // (GET /health) + HealthCheck(w http.ResponseWriter, r *http.Request) + // OrganizationsList List your Organizations (all, with organizations.manage) + // (GET /organizations) + OrganizationsList(w http.ResponseWriter, r *http.Request, params OrganizationsListParams) + // OrganizationsCreate Create an Organization (any active User; caller becomes org_admin) + // (POST /organizations) + OrganizationsCreate(w http.ResponseWriter, r *http.Request, params OrganizationsCreateParams) + // OrganizationsGet Fetch one Organization + // (GET /organizations/{id}) + OrganizationsGet(w http.ResponseWriter, r *http.Request, id string) + // OrganizationsUpdate Rename (org admin), or suspend/reactivate (organizations.manage) + // (PATCH /organizations/{id}) + OrganizationsUpdate(w http.ResponseWriter, r *http.Request, id string, params OrganizationsUpdateParams) + // OrganizationsListEntitlements List org-wide (seat) grants + // (GET /organizations/{id}/entitlements) + OrganizationsListEntitlements(w http.ResponseWriter, r *http.Request, id string, params OrganizationsListEntitlementsParams) + // OrganizationsGrantEntitlement Grant an app to the whole Organization + // (POST /organizations/{id}/entitlements) + OrganizationsGrantEntitlement(w http.ResponseWriter, r *http.Request, id string, params OrganizationsGrantEntitlementParams) + // OrganizationsListMembers List members + // (GET /organizations/{id}/members) + OrganizationsListMembers(w http.ResponseWriter, r *http.Request, id string, params OrganizationsListMembersParams) + // OrganizationsAddMember Invite a member by email (org admin), or add one directly by user_id (organizations.manage) + // (POST /organizations/{id}/members) + OrganizationsAddMember(w http.ResponseWriter, r *http.Request, id string) + // OrganizationsAcceptMembership Accept a pending membership (the invited User's own token) + // (POST /organizations/{id}/members/me/accept) + OrganizationsAcceptMembership(w http.ResponseWriter, r *http.Request, id string) + // OrganizationsRemoveMember Remove a member or withdraw an invitation; or leave or decline (userId = me) + // (DELETE /organizations/{id}/members/{userId}) + OrganizationsRemoveMember(w http.ResponseWriter, r *http.Request, id string, userId string) + // OrganizationsUpdateMember Change a member's role + // (PATCH /organizations/{id}/members/{userId}) + OrganizationsUpdateMember(w http.ResponseWriter, r *http.Request, id string, userId string) + // PermissionsList List every Permission key the caller can see + // (GET /permissions) + PermissionsList(w http.ResponseWriter, r *http.Request, params PermissionsListParams) + // RolesList List Roles + // (GET /roles) + RolesList(w http.ResponseWriter, r *http.Request, params RolesListParams) + // RolesCreate Define a Role + // (POST /roles) + RolesCreate(w http.ResponseWriter, r *http.Request) + // RolesDelete Delete an unassigned, non-seed Role + // (DELETE /roles/{id}) + RolesDelete(w http.ResponseWriter, r *http.Request, id string) + // RolesGet Fetch one Role + // (GET /roles/{id}) + RolesGet(w http.ResponseWriter, r *http.Request, id string) + // RolesUpdate Change a Role's permissions or description + // (PATCH /roles/{id}) + RolesUpdate(w http.ResponseWriter, r *http.Request, id string, params RolesUpdateParams) + // TenantsList List Tenants (your own; all with tenants.manage) + // (GET /tenants) + TenantsList(w http.ResponseWriter, r *http.Request, params TenantsListParams) + // TenantsGet Fetch one Tenant + // (GET /tenants/{id}) + TenantsGet(w http.ResponseWriter, r *http.Request, id string) + // BillingCreateCheckoutSession Start a Stripe Checkout to subscribe to Team (owner) + // (POST /tenants/{id}/billing/checkout-sessions) + BillingCreateCheckoutSession(w http.ResponseWriter, r *http.Request, id string, params BillingCreateCheckoutSessionParams) + // BillingCreatePortalSession Open the Stripe Customer Portal (owner or billing.manage) + // (POST /tenants/{id}/billing/portal-sessions) + BillingCreatePortalSession(w http.ResponseWriter, r *http.Request, id string) + // BillingGetSubscription Current plan, subscription status, period, and seats + // (GET /tenants/{id}/subscription) + BillingGetSubscription(w http.ResponseWriter, r *http.Request, id string) + // TenantsListTierChangeRequests List tier-change requests for a Tenant + // (GET /tenants/{id}/tier-change-requests) + TenantsListTierChangeRequests(w http.ResponseWriter, r *http.Request, id string, params TenantsListTierChangeRequestsParams) + // TenantsRequestTierChange Open a tier-change request on a customer's behalf (tenants.manage) + // (POST /tenants/{id}/tier-change-requests) + TenantsRequestTierChange(w http.ResponseWriter, r *http.Request, id string) + // TenantsGetTierChangeRequest Fetch one tier-change request + // (GET /tenants/{id}/tier-change-requests/{requestId}) + TenantsGetTierChangeRequest(w http.ResponseWriter, r *http.Request, id string, requestId string) + // TenantsUpdateTierChangeRequest Schedule, start, complete, or cancel a tier-change request (tenants.manage) + // (PATCH /tenants/{id}/tier-change-requests/{requestId}) + TenantsUpdateTierChangeRequest(w http.ResponseWriter, r *http.Request, id string, requestId string, params TenantsUpdateTierChangeRequestParams) + // BillingGetUsage Usage against every plan limit + // (GET /tenants/{id}/usage) + BillingGetUsage(w http.ResponseWriter, r *http.Request, id string) + // UsersList List or search users + // (GET /users) + UsersList(w http.ResponseWriter, r *http.Request, params UsersListParams) + // UsersCreate Create (invite) a user + // (POST /users) + UsersCreate(w http.ResponseWriter, r *http.Request, params UsersCreateParams) + // UsersDelete Soft-delete a user (self or users.manage) + // (DELETE /users/{id}) + UsersDelete(w http.ResponseWriter, r *http.Request, id UserIdOrMe) + // UsersGet Fetch one user + // (GET /users/{id}) + UsersGet(w http.ResponseWriter, r *http.Request, id UserIdOrMe) + // UsersUpdate Update email (self, pending verification) or status (users.manage) + // (PATCH /users/{id}) + UsersUpdate(w http.ResponseWriter, r *http.Request, id UserIdOrMe, params UsersUpdateParams) + // PermissionsGetEffective The live access check (always 200) + // (GET /users/{id}/apps/{appId}/effective-permissions) + PermissionsGetEffective(w http.ResponseWriter, r *http.Request, id UserIdOrMe, appId AppId) + // AppProfilesGet Fetch per-app AppProfile (self, the app's own key, or platform users.list; defaults if none written yet) + // (GET /users/{id}/apps/{appId}/profile) + AppProfilesGet(w http.ResponseWriter, r *http.Request, id UserIdOrMe, appId AppId) + // AppProfilesUpdate Update per-app AppProfile (custom is merged one level; null removes a key) + // (PATCH /users/{id}/apps/{appId}/profile) + AppProfilesUpdate(w http.ResponseWriter, r *http.Request, id UserIdOrMe, appId AppId, params AppProfilesUpdateParams) + // AppSettingsGet Fetch fully resolved per-app settings (self, the app's own key, or platform users.list) + // (GET /users/{id}/apps/{appId}/settings) + AppSettingsGet(w http.ResponseWriter, r *http.Request, id UserIdOrMe, appId AppId) + // AppSettingsUpdate Write per-app overrides (validated against settings_schema; null clears) + // (PATCH /users/{id}/apps/{appId}/settings) + AppSettingsUpdate(w http.ResponseWriter, r *http.Request, id UserIdOrMe, appId AppId, params AppSettingsUpdateParams) + // EntitlementsListForUser A user's entitlements, personal and org-sourced + // (GET /users/{id}/entitlements) + EntitlementsListForUser(w http.ResponseWriter, r *http.Request, id UserIdOrMe, params EntitlementsListForUserParams) + // EntitlementsGrant Grant a personal Entitlement + // (POST /users/{id}/entitlements) + EntitlementsGrant(w http.ResponseWriter, r *http.Request, id UserIdOrMe, params EntitlementsGrantParams) + // UsersRequestErasure Request right-to-erasure (soft-delete now, hard-delete cascade in 7 days) + // (POST /users/{id}/erasure-requests) + UsersRequestErasure(w http.ResponseWriter, r *http.Request, id UserIdOrMe) + // UsersCancelErasure Cancel a scheduled erasure inside its 7-day window (users.manage) + // (DELETE /users/{id}/erasure-requests/current) + UsersCancelErasure(w http.ResponseWriter, r *http.Request, id UserIdOrMe) + // UsersExport Export everything this API holds about this user + // (GET /users/{id}/export) + UsersExport(w http.ResponseWriter, r *http.Request, id UserIdOrMe) + // UsersResendInvitation Re-send the invitation email to an invited user (users.manage) + // (POST /users/{id}/invitation) + UsersResendInvitation(w http.ResponseWriter, r *http.Request, id UserIdOrMe) + // AuthResetMfaTotp Support reset of a user's TOTP (requires users.manage) + // (DELETE /users/{id}/mfa/totp) + AuthResetMfaTotp(w http.ResponseWriter, r *http.Request, id UserIdOrMe) + // AuthEnrollMfaTotp Begin TOTP enrollment (self only) + // (POST /users/{id}/mfa/totp) + AuthEnrollMfaTotp(w http.ResponseWriter, r *http.Request, id UserIdOrMe) + // AuthConfirmMfaTotp Confirm TOTP enrollment with a first code; returns recovery codes once + // (POST /users/{id}/mfa/totp/confirm) + AuthConfirmMfaTotp(w http.ResponseWriter, r *http.Request, id UserIdOrMe) + // AuthDisableMfaTotp Turn TOTP off given a current code or recovery code (self only) + // (POST /users/{id}/mfa/totp/disable) + AuthDisableMfaTotp(w http.ResponseWriter, r *http.Request, id UserIdOrMe) + // AuthChangePassword Change password given the current one (self only) + // (POST /users/{id}/password) + AuthChangePassword(w http.ResponseWriter, r *http.Request, id UserIdOrMe) + // ProfilesGet Fetch global Profile + // (GET /users/{id}/profile) + ProfilesGet(w http.ResponseWriter, r *http.Request, id UserIdOrMe) + // ProfilesUpdate Update global Profile (self or users.manage) + // (PATCH /users/{id}/profile) + ProfilesUpdate(w http.ResponseWriter, r *http.Request, id UserIdOrMe, params ProfilesUpdateParams) + // RolesListForUser List a user's Role assignments (including implicit default_app_role) + // (GET /users/{id}/roles) + RolesListForUser(w http.ResponseWriter, r *http.Request, id UserIdOrMe, params RolesListForUserParams) + // RolesRemove Remove a Role assignment (idempotent) + // (DELETE /users/{id}/roles/{roleId}) + RolesRemove(w http.ResponseWriter, r *http.Request, id UserIdOrMe, roleId string) + // RolesAssign Assign a Role (idempotent — 200 if already held) + // (POST /users/{id}/roles/{roleId}) + RolesAssign(w http.ResponseWriter, r *http.Request, id UserIdOrMe, roleId string) + // AuthListSessions List active sessions (self or users.manage) + // (GET /users/{id}/sessions) + AuthListSessions(w http.ResponseWriter, r *http.Request, id UserIdOrMe, params AuthListSessionsParams) + // AuthRevokeSession Revoke one session + // (DELETE /users/{id}/sessions/{sessionId}) + AuthRevokeSession(w http.ResponseWriter, r *http.Request, id UserIdOrMe, sessionId string) + // SettingsGet Fetch global Settings (self, or platform users.list) + // (GET /users/{id}/settings) + SettingsGet(w http.ResponseWriter, r *http.Request, id UserIdOrMe) + // SettingsUpdate Update global Settings (self or users.manage) + // (PATCH /users/{id}/settings) + SettingsUpdate(w http.ResponseWriter, r *http.Request, id UserIdOrMe, params SettingsUpdateParams) + // UsersSuspend Suspend a user (users.manage) + // (POST /users/{id}/suspend) + UsersSuspend(w http.ResponseWriter, r *http.Request, id UserIdOrMe) + // WebhooksList List subscriptions visible to the caller + // (GET /webhooks) + WebhooksList(w http.ResponseWriter, r *http.Request, params WebhooksListParams) + // WebhooksCreate Subscribe a URL to event types + // (POST /webhooks) + WebhooksCreate(w http.ResponseWriter, r *http.Request) + // WebhooksDelete Unsubscribe + // (DELETE /webhooks/{id}) + WebhooksDelete(w http.ResponseWriter, r *http.Request, id string) + // WebhooksGet Fetch one subscription + // (GET /webhooks/{id}) + WebhooksGet(w http.ResponseWriter, r *http.Request, id string) + // WebhooksUpdate Change url, events, description, api_version, or re-enable + // (PATCH /webhooks/{id}) + WebhooksUpdate(w http.ResponseWriter, r *http.Request, id string) + // WebhooksListDeliveries Recent delivery attempts (30-day log) + // (GET /webhooks/{id}/deliveries) + WebhooksListDeliveries(w http.ResponseWriter, r *http.Request, id string, params WebhooksListDeliveriesParams) + // WebhooksRedeliver Re-send one past event (same event id) + // (POST /webhooks/{id}/deliveries/{deliveryId}/redeliver) + WebhooksRedeliver(w http.ResponseWriter, r *http.Request, id string, deliveryId string) + // WebhooksRotateSecret Issue a new signing secret (24-hour overlap) + // (POST /webhooks/{id}/rotate-secret) + WebhooksRotateSecret(w http.ResponseWriter, r *http.Request, id string) + // WebhooksSendTest Send a webhook.test event now + // (POST /webhooks/{id}/test) + WebhooksSendTest(w http.ResponseWriter, r *http.Request, id string) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// ApiKeysList operation middleware +func (siw *ServerInterfaceWrapper) ApiKeysList(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params ApiKeysListParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "scope" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "scope", r.URL.Query(), ¶ms.Scope, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "scope"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "scope", Err: err}) + } + return + } + + // ------------- Optional query parameter "mode" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "mode", r.URL.Query(), ¶ms.Mode, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "mode"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "mode", Err: err}) + } + return + } + + // ------------- Optional query parameter "intended_use" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "intended_use", r.URL.Query(), ¶ms.IntendedUse, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "intended_use"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "intended_use", Err: err}) + } + return + } + + // ------------- Optional query parameter "include_inactive" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "include_inactive", r.URL.Query(), ¶ms.IncludeInactive, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "include_inactive"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "include_inactive", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ApiKeysList(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ApiKeysCreate operation middleware +func (siw *ServerInterfaceWrapper) ApiKeysCreate(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ApiKeysCreate(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ApiKeysDelete operation middleware +func (siw *ServerInterfaceWrapper) ApiKeysDelete(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ApiKeysDelete(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ApiKeysGet operation middleware +func (siw *ServerInterfaceWrapper) ApiKeysGet(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ApiKeysGet(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ApiKeysUpdate operation middleware +func (siw *ServerInterfaceWrapper) ApiKeysUpdate(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ApiKeysUpdate(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ApiKeysRotate operation middleware +func (siw *ServerInterfaceWrapper) ApiKeysRotate(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ApiKeysRotate(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ApplicationsList operation middleware +func (siw *ServerInterfaceWrapper) ApplicationsList(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params ApplicationsListParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "visibility" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "visibility", r.URL.Query(), ¶ms.Visibility, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "visibility"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "visibility", Err: err}) + } + return + } + + // ------------- Optional query parameter "review_status" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "review_status", r.URL.Query(), ¶ms.ReviewStatus, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "review_status"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "review_status", Err: err}) + } + return + } + + // ------------- Optional query parameter "owned" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "owned", r.URL.Query(), ¶ms.Owned, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "owned"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "owned", Err: err}) + } + return + } + + // ------------- Optional query parameter "tenant_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "tenant_id", r.URL.Query(), ¶ms.TenantId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "tenant_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "tenant_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "owner_user_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "owner_user_id", r.URL.Query(), ¶ms.OwnerUserId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "owner_user_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "owner_user_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "owner_organization_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "owner_organization_id", r.URL.Query(), ¶ms.OwnerOrganizationId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "owner_organization_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "owner_organization_id", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ApplicationsList(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ApplicationsCreate operation middleware +func (siw *ServerInterfaceWrapper) ApplicationsCreate(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params ApplicationsCreateParams + + headers := r.Header + + // ------------- Optional header parameter "Idempotency-Key" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("Idempotency-Key")]; found { + var IdempotencyKey IdempotencyKeyOptional + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "Idempotency-Key", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "Idempotency-Key", valueList[0], &IdempotencyKey, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "Idempotency-Key", Err: err}) + return + } + + params.IdempotencyKey = &IdempotencyKey + + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ApplicationsCreate(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ApplicationsGet operation middleware +func (siw *ServerInterfaceWrapper) ApplicationsGet(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ApplicationsGet(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ApplicationsUpdate operation middleware +func (siw *ServerInterfaceWrapper) ApplicationsUpdate(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params ApplicationsUpdateParams + + headers := r.Header + + // ------------- Optional header parameter "If-Match" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("If-Match")]; found { + var IfMatch IfMatch + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "If-Match", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "If-Match", valueList[0], &IfMatch, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "If-Match", Err: err}) + return + } + + params.IfMatch = &IfMatch + + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ApplicationsUpdate(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuditList operation middleware +func (siw *ServerInterfaceWrapper) AuditList(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params AuditListParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "target_user_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "target_user_id", r.URL.Query(), ¶ms.TargetUserId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "target_user_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "target_user_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "target_type" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "target_type", r.URL.Query(), ¶ms.TargetType, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "target_type"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "target_type", Err: err}) + } + return + } + + // ------------- Optional query parameter "target_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "target_id", r.URL.Query(), ¶ms.TargetId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "target_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "target_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "actor_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "actor_id", r.URL.Query(), ¶ms.ActorId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "actor_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "actor_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "actor_type" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "actor_type", r.URL.Query(), ¶ms.ActorType, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "actor_type"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "actor_type", Err: err}) + } + return + } + + // ------------- Optional query parameter "action" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "action", r.URL.Query(), ¶ms.Action, runtime.BindQueryParameterOptions{Type: "array", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "action"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "action", Err: err}) + } + return + } + + // ------------- Optional query parameter "application_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "application_id", r.URL.Query(), ¶ms.ApplicationId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "application_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "application_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "organization_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "organization_id", r.URL.Query(), ¶ms.OrganizationId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "organization_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "organization_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "tenant_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "tenant_id", r.URL.Query(), ¶ms.TenantId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "tenant_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "tenant_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "since" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "since", r.URL.Query(), ¶ms.Since, runtime.BindQueryParameterOptions{Type: "string", Format: "date-time"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "since"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "since", Err: err}) + } + return + } + + // ------------- Optional query parameter "until" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "until", r.URL.Query(), ¶ms.Until, runtime.BindQueryParameterOptions{Type: "string", Format: "date-time"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "until"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "until", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuditList(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuditGet operation middleware +func (siw *ServerInterfaceWrapper) AuditGet(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuditGet(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthCreateAppToken operation middleware +func (siw *ServerInterfaceWrapper) AuthCreateAppToken(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthCreateAppToken(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthVerifyEmail operation middleware +func (siw *ServerInterfaceWrapper) AuthVerifyEmail(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthVerifyEmail(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthResendEmailVerification operation middleware +func (siw *ServerInterfaceWrapper) AuthResendEmailVerification(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthResendEmailVerification(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthAcceptInvitation operation middleware +func (siw *ServerInterfaceWrapper) AuthAcceptInvitation(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthAcceptInvitation(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthLogin operation middleware +func (siw *ServerInterfaceWrapper) AuthLogin(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthLogin(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthLogout operation middleware +func (siw *ServerInterfaceWrapper) AuthLogout(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthLogout(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthVerifyMfa operation middleware +func (siw *ServerInterfaceWrapper) AuthVerifyMfa(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthVerifyMfa(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthCreateAuthorizationCode operation middleware +func (siw *ServerInterfaceWrapper) AuthCreateAuthorizationCode(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthCreateAuthorizationCode(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthExchangeOAuthToken operation middleware +func (siw *ServerInterfaceWrapper) AuthExchangeOAuthToken(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthExchangeOAuthToken(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthForgotPassword operation middleware +func (siw *ServerInterfaceWrapper) AuthForgotPassword(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthForgotPassword(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthResetPassword operation middleware +func (siw *ServerInterfaceWrapper) AuthResetPassword(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthResetPassword(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthSignup operation middleware +func (siw *ServerInterfaceWrapper) AuthSignup(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthSignup(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthSsoCallback operation middleware +func (siw *ServerInterfaceWrapper) AuthSsoCallback(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "provider" ------------- + var provider string + + err = runtime.BindStyledParameterWithOptions("simple", "provider", r.PathValue("provider"), &provider, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "provider", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthSsoCallback(w, r, provider) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthRefreshToken operation middleware +func (siw *ServerInterfaceWrapper) AuthRefreshToken(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthRefreshToken(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// EntitlementsList operation middleware +func (siw *ServerInterfaceWrapper) EntitlementsList(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params EntitlementsListParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "application_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "application_id", r.URL.Query(), ¶ms.ApplicationId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "application_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "application_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "status", r.URL.Query(), ¶ms.Status, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "status"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err}) + } + return + } + + // ------------- Optional query parameter "user_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "user_id", r.URL.Query(), ¶ms.UserId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "user_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "user_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "organization_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "organization_id", r.URL.Query(), ¶ms.OrganizationId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "organization_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "organization_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "source" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "source", r.URL.Query(), ¶ms.Source, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "source"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "source", Err: err}) + } + return + } + + // ------------- Optional query parameter "order_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "order_id", r.URL.Query(), ¶ms.OrderId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "order_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "order_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "ends_before" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "ends_before", r.URL.Query(), ¶ms.EndsBefore, runtime.BindQueryParameterOptions{Type: "string", Format: "date-time"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "ends_before"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "ends_before", Err: err}) + } + return + } + + // ------------- Optional query parameter "include_inactive" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "include_inactive", r.URL.Query(), ¶ms.IncludeInactive, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "include_inactive"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "include_inactive", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.EntitlementsList(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// EntitlementsDelete operation middleware +func (siw *ServerInterfaceWrapper) EntitlementsDelete(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.EntitlementsDelete(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// EntitlementsGet operation middleware +func (siw *ServerInterfaceWrapper) EntitlementsGet(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.EntitlementsGet(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// EntitlementsUpdate operation middleware +func (siw *ServerInterfaceWrapper) EntitlementsUpdate(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params EntitlementsUpdateParams + + headers := r.Header + + // ------------- Optional header parameter "If-Match" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("If-Match")]; found { + var IfMatch IfMatch + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "If-Match", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "If-Match", valueList[0], &IfMatch, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "If-Match", Err: err}) + return + } + + params.IfMatch = &IfMatch + + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.EntitlementsUpdate(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// HealthCheck operation middleware +func (siw *ServerInterfaceWrapper) HealthCheck(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.HealthCheck(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// OrganizationsList operation middleware +func (siw *ServerInterfaceWrapper) OrganizationsList(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params OrganizationsListParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "status", r.URL.Query(), ¶ms.Status, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "status"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err}) + } + return + } + + // ------------- Optional query parameter "q" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "q", r.URL.Query(), ¶ms.Q, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "q"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "q", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.OrganizationsList(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// OrganizationsCreate operation middleware +func (siw *ServerInterfaceWrapper) OrganizationsCreate(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params OrganizationsCreateParams + + headers := r.Header + + // ------------- Optional header parameter "Idempotency-Key" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("Idempotency-Key")]; found { + var IdempotencyKey IdempotencyKeyOptional + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "Idempotency-Key", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "Idempotency-Key", valueList[0], &IdempotencyKey, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "Idempotency-Key", Err: err}) + return + } + + params.IdempotencyKey = &IdempotencyKey + + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.OrganizationsCreate(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// OrganizationsGet operation middleware +func (siw *ServerInterfaceWrapper) OrganizationsGet(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.OrganizationsGet(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// OrganizationsUpdate operation middleware +func (siw *ServerInterfaceWrapper) OrganizationsUpdate(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params OrganizationsUpdateParams + + headers := r.Header + + // ------------- Optional header parameter "If-Match" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("If-Match")]; found { + var IfMatch IfMatch + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "If-Match", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "If-Match", valueList[0], &IfMatch, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "If-Match", Err: err}) + return + } + + params.IfMatch = &IfMatch + + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.OrganizationsUpdate(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// OrganizationsListEntitlements operation middleware +func (siw *ServerInterfaceWrapper) OrganizationsListEntitlements(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params OrganizationsListEntitlementsParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "application_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "application_id", r.URL.Query(), ¶ms.ApplicationId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "application_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "application_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "status", r.URL.Query(), ¶ms.Status, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "status"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err}) + } + return + } + + // ------------- Optional query parameter "order_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "order_id", r.URL.Query(), ¶ms.OrderId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "order_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "order_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "ends_before" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "ends_before", r.URL.Query(), ¶ms.EndsBefore, runtime.BindQueryParameterOptions{Type: "string", Format: "date-time"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "ends_before"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "ends_before", Err: err}) + } + return + } + + // ------------- Optional query parameter "include_inactive" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "include_inactive", r.URL.Query(), ¶ms.IncludeInactive, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "include_inactive"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "include_inactive", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.OrganizationsListEntitlements(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// OrganizationsGrantEntitlement operation middleware +func (siw *ServerInterfaceWrapper) OrganizationsGrantEntitlement(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params OrganizationsGrantEntitlementParams + + headers := r.Header + + // ------------- Required header parameter "Idempotency-Key" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("Idempotency-Key")]; found { + var IdempotencyKey IdempotencyKey + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "Idempotency-Key", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "Idempotency-Key", valueList[0], &IdempotencyKey, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "Idempotency-Key", Err: err}) + return + } + + params.IdempotencyKey = IdempotencyKey + + } else { + err := fmt.Errorf("Header parameter Idempotency-Key is required, but not found") + siw.ErrorHandlerFunc(w, r, &RequiredHeaderError{ParamName: "Idempotency-Key", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.OrganizationsGrantEntitlement(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// OrganizationsListMembers operation middleware +func (siw *ServerInterfaceWrapper) OrganizationsListMembers(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params OrganizationsListMembersParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "role" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "role", r.URL.Query(), ¶ms.Role, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "role"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "role", Err: err}) + } + return + } + + // ------------- Optional query parameter "membership_status" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "membership_status", r.URL.Query(), ¶ms.MembershipStatus, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "membership_status"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "membership_status", Err: err}) + } + return + } + + // ------------- Optional query parameter "q" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "q", r.URL.Query(), ¶ms.Q, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "q"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "q", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.OrganizationsListMembers(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// OrganizationsAddMember operation middleware +func (siw *ServerInterfaceWrapper) OrganizationsAddMember(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.OrganizationsAddMember(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// OrganizationsAcceptMembership operation middleware +func (siw *ServerInterfaceWrapper) OrganizationsAcceptMembership(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.OrganizationsAcceptMembership(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// OrganizationsRemoveMember operation middleware +func (siw *ServerInterfaceWrapper) OrganizationsRemoveMember(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "userId" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "userId", r.PathValue("userId"), &userId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "userId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.OrganizationsRemoveMember(w, r, id, userId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// OrganizationsUpdateMember operation middleware +func (siw *ServerInterfaceWrapper) OrganizationsUpdateMember(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "userId" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "userId", r.PathValue("userId"), &userId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "userId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.OrganizationsUpdateMember(w, r, id, userId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// PermissionsList operation middleware +func (siw *ServerInterfaceWrapper) PermissionsList(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params PermissionsListParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "scope" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "scope", r.URL.Query(), ¶ms.Scope, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "scope"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "scope", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PermissionsList(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RolesList operation middleware +func (siw *ServerInterfaceWrapper) RolesList(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params RolesListParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "scope" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "scope", r.URL.Query(), ¶ms.Scope, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "scope"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "scope", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RolesList(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RolesCreate operation middleware +func (siw *ServerInterfaceWrapper) RolesCreate(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RolesCreate(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RolesDelete operation middleware +func (siw *ServerInterfaceWrapper) RolesDelete(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RolesDelete(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RolesGet operation middleware +func (siw *ServerInterfaceWrapper) RolesGet(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RolesGet(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RolesUpdate operation middleware +func (siw *ServerInterfaceWrapper) RolesUpdate(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params RolesUpdateParams + + headers := r.Header + + // ------------- Optional header parameter "If-Match" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("If-Match")]; found { + var IfMatch IfMatch + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "If-Match", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "If-Match", valueList[0], &IfMatch, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "If-Match", Err: err}) + return + } + + params.IfMatch = &IfMatch + + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RolesUpdate(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// TenantsList operation middleware +func (siw *ServerInterfaceWrapper) TenantsList(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params TenantsListParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "plan" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "plan", r.URL.Query(), ¶ms.Plan, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "plan"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "plan", Err: err}) + } + return + } + + // ------------- Optional query parameter "tier" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "tier", r.URL.Query(), ¶ms.Tier, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "tier"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "tier", Err: err}) + } + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "status", r.URL.Query(), ¶ms.Status, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "status"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err}) + } + return + } + + // ------------- Optional query parameter "restricted" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "restricted", r.URL.Query(), ¶ms.Restricted, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "restricted"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "restricted", Err: err}) + } + return + } + + // ------------- Optional query parameter "owner_user_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "owner_user_id", r.URL.Query(), ¶ms.OwnerUserId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "owner_user_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "owner_user_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "owner_organization_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "owner_organization_id", r.URL.Query(), ¶ms.OwnerOrganizationId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "owner_organization_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "owner_organization_id", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.TenantsList(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// TenantsGet operation middleware +func (siw *ServerInterfaceWrapper) TenantsGet(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.TenantsGet(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// BillingCreateCheckoutSession operation middleware +func (siw *ServerInterfaceWrapper) BillingCreateCheckoutSession(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params BillingCreateCheckoutSessionParams + + headers := r.Header + + // ------------- Required header parameter "Idempotency-Key" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("Idempotency-Key")]; found { + var IdempotencyKey IdempotencyKey + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "Idempotency-Key", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "Idempotency-Key", valueList[0], &IdempotencyKey, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "Idempotency-Key", Err: err}) + return + } + + params.IdempotencyKey = IdempotencyKey + + } else { + err := fmt.Errorf("Header parameter Idempotency-Key is required, but not found") + siw.ErrorHandlerFunc(w, r, &RequiredHeaderError{ParamName: "Idempotency-Key", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.BillingCreateCheckoutSession(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// BillingCreatePortalSession operation middleware +func (siw *ServerInterfaceWrapper) BillingCreatePortalSession(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.BillingCreatePortalSession(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// BillingGetSubscription operation middleware +func (siw *ServerInterfaceWrapper) BillingGetSubscription(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.BillingGetSubscription(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// TenantsListTierChangeRequests operation middleware +func (siw *ServerInterfaceWrapper) TenantsListTierChangeRequests(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params TenantsListTierChangeRequestsParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.TenantsListTierChangeRequests(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// TenantsRequestTierChange operation middleware +func (siw *ServerInterfaceWrapper) TenantsRequestTierChange(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.TenantsRequestTierChange(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// TenantsGetTierChangeRequest operation middleware +func (siw *ServerInterfaceWrapper) TenantsGetTierChangeRequest(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "requestId" ------------- + var requestId string + + err = runtime.BindStyledParameterWithOptions("simple", "requestId", r.PathValue("requestId"), &requestId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "requestId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.TenantsGetTierChangeRequest(w, r, id, requestId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// TenantsUpdateTierChangeRequest operation middleware +func (siw *ServerInterfaceWrapper) TenantsUpdateTierChangeRequest(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "requestId" ------------- + var requestId string + + err = runtime.BindStyledParameterWithOptions("simple", "requestId", r.PathValue("requestId"), &requestId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "requestId", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params TenantsUpdateTierChangeRequestParams + + headers := r.Header + + // ------------- Optional header parameter "If-Match" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("If-Match")]; found { + var IfMatch IfMatch + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "If-Match", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "If-Match", valueList[0], &IfMatch, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "If-Match", Err: err}) + return + } + + params.IfMatch = &IfMatch + + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.TenantsUpdateTierChangeRequest(w, r, id, requestId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// BillingGetUsage operation middleware +func (siw *ServerInterfaceWrapper) BillingGetUsage(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.BillingGetUsage(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UsersList operation middleware +func (siw *ServerInterfaceWrapper) UsersList(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params UsersListParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "status", r.URL.Query(), ¶ms.Status, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "status"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err}) + } + return + } + + // ------------- Optional query parameter "email" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "email", r.URL.Query(), ¶ms.Email, runtime.BindQueryParameterOptions{Type: "string", Format: "email"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "email"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "email", Err: err}) + } + return + } + + // ------------- Optional query parameter "q" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "q", r.URL.Query(), ¶ms.Q, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "q"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "q", Err: err}) + } + return + } + + // ------------- Optional query parameter "application_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "application_id", r.URL.Query(), ¶ms.ApplicationId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "application_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "application_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "organization_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "organization_id", r.URL.Query(), ¶ms.OrganizationId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "organization_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "organization_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "created_after" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "created_after", r.URL.Query(), ¶ms.CreatedAfter, runtime.BindQueryParameterOptions{Type: "string", Format: "date-time"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "created_after"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "created_after", Err: err}) + } + return + } + + // ------------- Optional query parameter "created_before" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "created_before", r.URL.Query(), ¶ms.CreatedBefore, runtime.BindQueryParameterOptions{Type: "string", Format: "date-time"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "created_before"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "created_before", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UsersList(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UsersCreate operation middleware +func (siw *ServerInterfaceWrapper) UsersCreate(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params UsersCreateParams + + headers := r.Header + + // ------------- Optional header parameter "Idempotency-Key" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("Idempotency-Key")]; found { + var IdempotencyKey IdempotencyKeyOptional + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "Idempotency-Key", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "Idempotency-Key", valueList[0], &IdempotencyKey, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "Idempotency-Key", Err: err}) + return + } + + params.IdempotencyKey = &IdempotencyKey + + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UsersCreate(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UsersDelete operation middleware +func (siw *ServerInterfaceWrapper) UsersDelete(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UsersDelete(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UsersGet operation middleware +func (siw *ServerInterfaceWrapper) UsersGet(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UsersGet(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UsersUpdate operation middleware +func (siw *ServerInterfaceWrapper) UsersUpdate(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params UsersUpdateParams + + headers := r.Header + + // ------------- Optional header parameter "If-Match" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("If-Match")]; found { + var IfMatch IfMatch + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "If-Match", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "If-Match", valueList[0], &IfMatch, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "If-Match", Err: err}) + return + } + + params.IfMatch = &IfMatch + + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UsersUpdate(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// PermissionsGetEffective operation middleware +func (siw *ServerInterfaceWrapper) PermissionsGetEffective(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "appId" ------------- + var appId AppId + + err = runtime.BindStyledParameterWithOptions("simple", "appId", r.PathValue("appId"), &appId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "appId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PermissionsGetEffective(w, r, id, appId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AppProfilesGet operation middleware +func (siw *ServerInterfaceWrapper) AppProfilesGet(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "appId" ------------- + var appId AppId + + err = runtime.BindStyledParameterWithOptions("simple", "appId", r.PathValue("appId"), &appId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "appId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AppProfilesGet(w, r, id, appId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AppProfilesUpdate operation middleware +func (siw *ServerInterfaceWrapper) AppProfilesUpdate(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "appId" ------------- + var appId AppId + + err = runtime.BindStyledParameterWithOptions("simple", "appId", r.PathValue("appId"), &appId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "appId", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params AppProfilesUpdateParams + + headers := r.Header + + // ------------- Optional header parameter "If-Match" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("If-Match")]; found { + var IfMatch IfMatch + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "If-Match", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "If-Match", valueList[0], &IfMatch, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "If-Match", Err: err}) + return + } + + params.IfMatch = &IfMatch + + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AppProfilesUpdate(w, r, id, appId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AppSettingsGet operation middleware +func (siw *ServerInterfaceWrapper) AppSettingsGet(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "appId" ------------- + var appId AppId + + err = runtime.BindStyledParameterWithOptions("simple", "appId", r.PathValue("appId"), &appId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "appId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AppSettingsGet(w, r, id, appId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AppSettingsUpdate operation middleware +func (siw *ServerInterfaceWrapper) AppSettingsUpdate(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "appId" ------------- + var appId AppId + + err = runtime.BindStyledParameterWithOptions("simple", "appId", r.PathValue("appId"), &appId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "appId", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params AppSettingsUpdateParams + + headers := r.Header + + // ------------- Optional header parameter "If-Match" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("If-Match")]; found { + var IfMatch IfMatch + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "If-Match", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "If-Match", valueList[0], &IfMatch, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "If-Match", Err: err}) + return + } + + params.IfMatch = &IfMatch + + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AppSettingsUpdate(w, r, id, appId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// EntitlementsListForUser operation middleware +func (siw *ServerInterfaceWrapper) EntitlementsListForUser(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params EntitlementsListForUserParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "application_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "application_id", r.URL.Query(), ¶ms.ApplicationId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "application_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "application_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "status", r.URL.Query(), ¶ms.Status, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "status"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err}) + } + return + } + + // ------------- Optional query parameter "source" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "source", r.URL.Query(), ¶ms.Source, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "source"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "source", Err: err}) + } + return + } + + // ------------- Optional query parameter "include_inactive" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "include_inactive", r.URL.Query(), ¶ms.IncludeInactive, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "include_inactive"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "include_inactive", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.EntitlementsListForUser(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// EntitlementsGrant operation middleware +func (siw *ServerInterfaceWrapper) EntitlementsGrant(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params EntitlementsGrantParams + + headers := r.Header + + // ------------- Required header parameter "Idempotency-Key" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("Idempotency-Key")]; found { + var IdempotencyKey IdempotencyKey + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "Idempotency-Key", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "Idempotency-Key", valueList[0], &IdempotencyKey, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "Idempotency-Key", Err: err}) + return + } + + params.IdempotencyKey = IdempotencyKey + + } else { + err := fmt.Errorf("Header parameter Idempotency-Key is required, but not found") + siw.ErrorHandlerFunc(w, r, &RequiredHeaderError{ParamName: "Idempotency-Key", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.EntitlementsGrant(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UsersRequestErasure operation middleware +func (siw *ServerInterfaceWrapper) UsersRequestErasure(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UsersRequestErasure(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UsersCancelErasure operation middleware +func (siw *ServerInterfaceWrapper) UsersCancelErasure(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UsersCancelErasure(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UsersExport operation middleware +func (siw *ServerInterfaceWrapper) UsersExport(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UsersExport(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UsersResendInvitation operation middleware +func (siw *ServerInterfaceWrapper) UsersResendInvitation(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UsersResendInvitation(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthResetMfaTotp operation middleware +func (siw *ServerInterfaceWrapper) AuthResetMfaTotp(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthResetMfaTotp(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthEnrollMfaTotp operation middleware +func (siw *ServerInterfaceWrapper) AuthEnrollMfaTotp(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthEnrollMfaTotp(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthConfirmMfaTotp operation middleware +func (siw *ServerInterfaceWrapper) AuthConfirmMfaTotp(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthConfirmMfaTotp(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthDisableMfaTotp operation middleware +func (siw *ServerInterfaceWrapper) AuthDisableMfaTotp(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthDisableMfaTotp(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthChangePassword operation middleware +func (siw *ServerInterfaceWrapper) AuthChangePassword(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthChangePassword(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ProfilesGet operation middleware +func (siw *ServerInterfaceWrapper) ProfilesGet(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ProfilesGet(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ProfilesUpdate operation middleware +func (siw *ServerInterfaceWrapper) ProfilesUpdate(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params ProfilesUpdateParams + + headers := r.Header + + // ------------- Optional header parameter "If-Match" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("If-Match")]; found { + var IfMatch IfMatch + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "If-Match", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "If-Match", valueList[0], &IfMatch, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "If-Match", Err: err}) + return + } + + params.IfMatch = &IfMatch + + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ProfilesUpdate(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RolesListForUser operation middleware +func (siw *ServerInterfaceWrapper) RolesListForUser(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params RolesListForUserParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "application_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "application_id", r.URL.Query(), ¶ms.ApplicationId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "application_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "application_id", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RolesListForUser(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RolesRemove operation middleware +func (siw *ServerInterfaceWrapper) RolesRemove(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "roleId" ------------- + var roleId string + + err = runtime.BindStyledParameterWithOptions("simple", "roleId", r.PathValue("roleId"), &roleId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "roleId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RolesRemove(w, r, id, roleId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RolesAssign operation middleware +func (siw *ServerInterfaceWrapper) RolesAssign(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "roleId" ------------- + var roleId string + + err = runtime.BindStyledParameterWithOptions("simple", "roleId", r.PathValue("roleId"), &roleId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "roleId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RolesAssign(w, r, id, roleId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthListSessions operation middleware +func (siw *ServerInterfaceWrapper) AuthListSessions(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params AuthListSessionsParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthListSessions(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthRevokeSession operation middleware +func (siw *ServerInterfaceWrapper) AuthRevokeSession(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "sessionId" ------------- + var sessionId string + + err = runtime.BindStyledParameterWithOptions("simple", "sessionId", r.PathValue("sessionId"), &sessionId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sessionId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthRevokeSession(w, r, id, sessionId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// SettingsGet operation middleware +func (siw *ServerInterfaceWrapper) SettingsGet(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SettingsGet(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// SettingsUpdate operation middleware +func (siw *ServerInterfaceWrapper) SettingsUpdate(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params SettingsUpdateParams + + headers := r.Header + + // ------------- Optional header parameter "If-Match" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("If-Match")]; found { + var IfMatch IfMatch + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "If-Match", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "If-Match", valueList[0], &IfMatch, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "If-Match", Err: err}) + return + } + + params.IfMatch = &IfMatch + + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SettingsUpdate(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UsersSuspend operation middleware +func (siw *ServerInterfaceWrapper) UsersSuspend(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id UserIdOrMe + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UsersSuspend(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// WebhooksList operation middleware +func (siw *ServerInterfaceWrapper) WebhooksList(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params WebhooksListParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "application_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "application_id", r.URL.Query(), ¶ms.ApplicationId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "application_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "application_id", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.WebhooksList(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// WebhooksCreate operation middleware +func (siw *ServerInterfaceWrapper) WebhooksCreate(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.WebhooksCreate(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// WebhooksDelete operation middleware +func (siw *ServerInterfaceWrapper) WebhooksDelete(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.WebhooksDelete(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// WebhooksGet operation middleware +func (siw *ServerInterfaceWrapper) WebhooksGet(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.WebhooksGet(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// WebhooksUpdate operation middleware +func (siw *ServerInterfaceWrapper) WebhooksUpdate(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.WebhooksUpdate(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// WebhooksListDeliveries operation middleware +func (siw *ServerInterfaceWrapper) WebhooksListDeliveries(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params WebhooksListDeliveriesParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "event_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "event_id", r.URL.Query(), ¶ms.EventId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "event_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "event_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "status", r.URL.Query(), ¶ms.Status, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "status"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err}) + } + return + } + + // ------------- Optional query parameter "since" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "since", r.URL.Query(), ¶ms.Since, runtime.BindQueryParameterOptions{Type: "string", Format: "date-time"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "since"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "since", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.WebhooksListDeliveries(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// WebhooksRedeliver operation middleware +func (siw *ServerInterfaceWrapper) WebhooksRedeliver(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "deliveryId" ------------- + var deliveryId string + + err = runtime.BindStyledParameterWithOptions("simple", "deliveryId", r.PathValue("deliveryId"), &deliveryId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "deliveryId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.WebhooksRedeliver(w, r, id, deliveryId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// WebhooksRotateSecret operation middleware +func (siw *ServerInterfaceWrapper) WebhooksRotateSecret(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.WebhooksRotateSecret(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// WebhooksSendTest operation middleware +func (siw *ServerInterfaceWrapper) WebhooksSendTest(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.WebhooksSendTest(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{}) +} + +// ServeMux is an abstraction of [http.ServeMux]. +type ServeMux interface { + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) + http.Handler +} + +type StdHTTPServerOptions struct { + BaseURL string + BaseRouter ServeMux + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseRouter: m, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, m ServeMux, baseURL string) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseURL: baseURL, + BaseRouter: m, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.Handler { + m := options.BaseRouter + + if m == nil { + m = http.NewServeMux() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/health", wrapper.HealthCheck) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/signup", wrapper.AuthSignup) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/login", wrapper.AuthLogin) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/mfa/verify", wrapper.AuthVerifyMfa) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/token/refresh", wrapper.AuthRefreshToken) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/logout", wrapper.AuthLogout) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/app-tokens", wrapper.AuthCreateAppToken) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/oauth/authorization-codes", wrapper.AuthCreateAuthorizationCode) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/oauth/token", wrapper.AuthExchangeOAuthToken) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/email/verify", wrapper.AuthVerifyEmail) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/email/verify/resend", wrapper.AuthResendEmailVerification) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/password/forgot", wrapper.AuthForgotPassword) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/password/reset", wrapper.AuthResetPassword) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/invitations/accept", wrapper.AuthAcceptInvitation) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/sso/{provider}/callback", wrapper.AuthSsoCallback) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/password", wrapper.AuthChangePassword) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}/mfa/totp", wrapper.AuthResetMfaTotp) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/mfa/totp", wrapper.AuthEnrollMfaTotp) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/mfa/totp/confirm", wrapper.AuthConfirmMfaTotp) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/mfa/totp/disable", wrapper.AuthDisableMfaTotp) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/sessions", wrapper.AuthListSessions) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}/sessions/{sessionId}", wrapper.AuthRevokeSession) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users", wrapper.UsersList) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users", wrapper.UsersCreate) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}", wrapper.UsersDelete) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}", wrapper.UsersGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}", wrapper.UsersUpdate) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/suspend", wrapper.UsersSuspend) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/invitation", wrapper.UsersResendInvitation) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/export", wrapper.UsersExport) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/erasure-requests", wrapper.UsersRequestErasure) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}/erasure-requests/current", wrapper.UsersCancelErasure) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/profile", wrapper.ProfilesGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}/profile", wrapper.ProfilesUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/settings", wrapper.SettingsGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}/settings", wrapper.SettingsUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/apps/{appId}/profile", wrapper.AppProfilesGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}/apps/{appId}/profile", wrapper.AppProfilesUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/apps/{appId}/settings", wrapper.AppSettingsGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}/apps/{appId}/settings", wrapper.AppSettingsUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/applications", wrapper.ApplicationsList) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/applications", wrapper.ApplicationsCreate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/applications/{id}", wrapper.ApplicationsGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/applications/{id}", wrapper.ApplicationsUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/entitlements", wrapper.EntitlementsListForUser) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/entitlements", wrapper.EntitlementsGrant) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/entitlements", wrapper.EntitlementsList) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/entitlements/{id}", wrapper.EntitlementsDelete) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/entitlements/{id}", wrapper.EntitlementsGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/entitlements/{id}", wrapper.EntitlementsUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/permissions", wrapper.PermissionsList) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/roles", wrapper.RolesList) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/roles", wrapper.RolesCreate) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/roles/{id}", wrapper.RolesDelete) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/roles/{id}", wrapper.RolesGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/roles/{id}", wrapper.RolesUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/roles", wrapper.RolesListForUser) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}/roles/{roleId}", wrapper.RolesRemove) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/roles/{roleId}", wrapper.RolesAssign) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/apps/{appId}/effective-permissions", wrapper.PermissionsGetEffective) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/organizations", wrapper.OrganizationsList) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations", wrapper.OrganizationsCreate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/organizations/{id}", wrapper.OrganizationsGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/organizations/{id}", wrapper.OrganizationsUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/organizations/{id}/members", wrapper.OrganizationsListMembers) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/members", wrapper.OrganizationsAddMember) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/members/me/accept", wrapper.OrganizationsAcceptMembership) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/organizations/{id}/members/{userId}", wrapper.OrganizationsRemoveMember) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/organizations/{id}/members/{userId}", wrapper.OrganizationsUpdateMember) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/organizations/{id}/entitlements", wrapper.OrganizationsListEntitlements) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/entitlements", wrapper.OrganizationsGrantEntitlement) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/tenants", wrapper.TenantsList) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/tenants/{id}", wrapper.TenantsGet) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/tenants/{id}/tier-change-requests", wrapper.TenantsListTierChangeRequests) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/tenants/{id}/tier-change-requests", wrapper.TenantsRequestTierChange) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/tenants/{id}/tier-change-requests/{requestId}", wrapper.TenantsGetTierChangeRequest) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/tenants/{id}/tier-change-requests/{requestId}", wrapper.TenantsUpdateTierChangeRequest) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/tenants/{id}/subscription", wrapper.BillingGetSubscription) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/tenants/{id}/usage", wrapper.BillingGetUsage) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/tenants/{id}/billing/checkout-sessions", wrapper.BillingCreateCheckoutSession) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/tenants/{id}/billing/portal-sessions", wrapper.BillingCreatePortalSession) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/audit-events", wrapper.AuditList) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/audit-events/{id}", wrapper.AuditGet) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/webhooks", wrapper.WebhooksList) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/webhooks", wrapper.WebhooksCreate) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/webhooks/{id}", wrapper.WebhooksDelete) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/webhooks/{id}", wrapper.WebhooksGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/webhooks/{id}", wrapper.WebhooksUpdate) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/webhooks/{id}/rotate-secret", wrapper.WebhooksRotateSecret) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/webhooks/{id}/test", wrapper.WebhooksSendTest) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/webhooks/{id}/deliveries", wrapper.WebhooksListDeliveries) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/webhooks/{id}/deliveries/{deliveryId}/redeliver", wrapper.WebhooksRedeliver) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api-keys", wrapper.ApiKeysList) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api-keys", wrapper.ApiKeysCreate) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/api-keys/{id}", wrapper.ApiKeysDelete) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api-keys/{id}", wrapper.ApiKeysGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/api-keys/{id}", wrapper.ApiKeysUpdate) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api-keys/{id}/rotate", wrapper.ApiKeysRotate) + + return m +} diff --git a/internal/api/gen/generate.go b/internal/api/gen/generate.go new file mode 100644 index 0000000..9acf426 --- /dev/null +++ b/internal/api/gen/generate.go @@ -0,0 +1,5 @@ +// Package gen is generated from docs/openapi.yaml. Never edit gen.go by hand; +// run `make gen` (go generate ./internal/api/gen) after changing the spec. +package gen + +//go:generate go tool oapi-codegen -config oapi-codegen.yaml ../../../docs/openapi.yaml diff --git a/internal/api/gen/oapi-codegen.yaml b/internal/api/gen/oapi-codegen.yaml new file mode 100644 index 0000000..eac8384 --- /dev/null +++ b/internal/api/gen/oapi-codegen.yaml @@ -0,0 +1,12 @@ +# Generates the route table, path/query/header parameter binding, and the +# ServerInterface every operation in docs/openapi.yaml must implement. +# Request/response bodies are hand-written in internal/core instead: the +# generated models can't express explicit JSON nulls or PATCH presence, +# both of which Conventions requires. Contract tests validate bodies. +package: gen +output: gen.go +generate: + models: true + std-http-server: true +output-options: + skip-prune: true diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..44e4f9b --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,79 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "testing" + "time" +) + +func TestPasswords(t *testing.T) { + h, err := HashPassword("correct horse battery staple") + if err != nil { + t.Fatal(err) + } + if !VerifyPassword("correct horse battery staple", h) || VerifyPassword("wrong", h) { + t.Fatal("verify") + } + for pw, reason := range map[string]string{"short": "too_short", "jordan@example.com": "matches_email", "passwordpassword": "breached"} { + if r, _ := WeakReason(CheckPolicy(pw, "jordan@example.com")); r != reason { + t.Errorf("%s: %s, want %s", pw, r, reason) + } + } + if CheckPolicy("a perfectly fine passphrase", "x@example.com") != nil { + t.Fatal("a long unique passphrase passes") + } +} + +func TestTOTP(t *testing.T) { + s := NewTOTPSecret() + now := time.Unix(1_800_000_000, 0) + code := TOTPCode(s, now) + step, ok := VerifyTOTP(s, code, now, 0) + if !ok { + t.Fatal("current code") + } + if _, ok := VerifyTOTP(s, code, now, step); ok { + t.Fatal("a code can't be reused in its window") + } + if _, ok := VerifyTOTP(s, TOTPCode(s, now.Add(-30*time.Second)), now, 0); !ok { + t.Fatal("±1 step of drift") + } + if _, ok := VerifyTOTP(s, TOTPCode(s, now.Add(-90*time.Second)), now, 0); ok { + t.Fatal("3 steps old is out of window") + } + // RFC 6238 test vector (SHA-1, T=59): 94287082 → 6 digits 287082. + if got := TOTPCode("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", time.Unix(59, 0)); got != "287082" { + t.Fatalf("RFC vector = %s", got) + } +} + +func TestJWT(t *testing.T) { + key, _ := rsa.GenerateKey(rand.Reader, 2048) + s := NewLocalSignerFromKey(key) + tok, err := SignJWT(context.Background(), s, map[string]any{"sub": "usr_1"}) + if err != nil { + t.Fatal(err) + } + var claims map[string]any + if err := VerifyJWT(tok, s.PublicKeys(), &claims); err != nil || claims["sub"] != "usr_1" { + t.Fatalf("verify = %v %v", claims, err) + } + other, _ := rsa.GenerateKey(rand.Reader, 2048) + if VerifyJWT(tok, NewLocalSignerFromKey(other).PublicKeys(), &claims) == nil { + t.Fatal("a token must not verify under another key") + } + if VerifyJWT(tok[:len(tok)-4]+"AAAA", s.PublicKeys(), &claims) == nil { + t.Fatal("a tampered signature must not verify") + } +} + +func TestLocalEncrypter(t *testing.T) { + e, _ := NewLocalEncrypter("") + ct, _ := e.Encrypt(context.Background(), "whsec_x") + pt, err := e.Decrypt(context.Background(), ct) + if err != nil || pt != "whsec_x" { + t.Fatalf("round trip = %q %v", pt, err) + } +} diff --git a/internal/auth/breached.txt b/internal/auth/breached.txt new file mode 100644 index 0000000..1350055 --- /dev/null +++ b/internal/auth/breached.txt @@ -0,0 +1,1201 @@ +# Entries of 12+ characters from SecLists 100k-most-used-passwords-NCSC.txt +# (https://github.com/danielmiessler/SecLists, MIT License; the UK NCSC top +# 100,000 breached passwords). Shorter entries are omitted because the +# 12-character minimum already rejects them. Lowercased and deduplicated. +йцукен +любимая +кристина +анастасия +йцукенгшщз +йцукен +???????????? +??????????????? +??????@mail.ru +@elit-centr.com.ua +@gmail.com.mx +пїЅпїЅпїЅпїЅпїЅпїЅ@mail.ru +++++++@mail.ru +$hex[687474703a2f2f616473] +$hex[687474703a2f2f777777] +000000000000 +000000000000000 +00000000000000000000 +000webhost.com +010203010203 +010203040506 +010203040506070809 +011151zangetsu +012345678910 +014702580369 +05081980bija +07061985nina +0r968ji9ufj6 +102030102030 +102030405060 +102030405060708090 +110331rahili +111111111111 +1111111111111 +11111111111111 +111111111111111 +11111111111111111111 +111111prof_root2.sql.txt:, +111111prof_root3.sql.txt:, +111122223333 +111222333444 +111222333444555 +111222tianya +112233112233 +112233445566 +11223344556677 +1122334455667788 +112233445566778899 +11223344556677889900 +112358132134 +121212121212 +1213141516171819 +122333444455555 +123012301230 +123123123123 +123123123123123 +123123456456 +123123qweqwe +123321123321 +1233211234567 +123321456654 +123321qweewq +1й2ц3у4к +123412341234 +1й2ц3у4к5е +123451234512345 +123456123456 +123456654321 +12345671234567 +1234567654321 +12345677654321 +1234567812345678 +1234567887654321 +1234567890-= +123456789000 +12345678900987654321 +123456789012 +1234567890123 +123456789012345 +1234567890123456 +12345678901234567890 +1234567890987654321 +1234567890abc +1234567890qaz +1234567890qw +1234567890qwe +1234567890qwer +1234567890qwert +1234567890qwerty +1234567890qwertyuiop +123456789101 +1234567891011 +123456789101112 +12345678910a +123456789123 +1234567891234 +12345678912345 +123456789123456 +1234567891234567 +123456789123456789 +123456789789 +12345678987654321 +123456789987 +123456789987654 +1234567899876543 +123456789987654321 +123456789aaa +123456789abc +123456789abcd +123456789abcde +123456789asd +123456789asdf +123456789lol +123456789love +123456789qaz +123456789qqq +123456789qwe +123456789qwer +123456789qwert +123456789qwerty +123456789qwertyuio +123456789qwertyuiop +123456789zxc +123456789zxcvbnm +123456789zzz +12345678qwertyui +1234567qwerty +1234567qwertyu +123456abcdef +123456asdfgh +123456prof_root2.sql.txt:, +123456prof_root3.sql.txt:, +123456qwerty +123456zxcvbn +12345qwertasdfg +1234qwerasdf +1234qwerasdfzxcv +123abc123abc +123admin321a +123asd123asd +123ewqasdcxz +123qwe123qwe +123qwe456asd +123qwe456rty +123qweasdzxc +123qwerty123 +12qw34er56ty +1324354657687980 +1580@welcamino +1580welcamino +159357159357 +159357258456 +159753159753 +159753258456 +159753456852 +159753852456 +19761968serg +198719871987 +1a2a3a4a5a6a +1a2b3c4d5e6f +1a2s3d4f5g6h +1q2w3e1q2w3e +1q2w3e4r5t6y +1q2w3e4r5t6y7 +1q2w3e4r5t6y7u +1q2w3e4r5t6y7u8 +1q2w3e4r5t6y7u8i +1q2w3e4r5t6y7u8i9o +1q2w3e4r5t6y7u8i9o0p +1q2w3e4r5t6z +1qa2ws3ed4rf +1qa2ws3ed4rf5tg +1qaz2wsx3edc +1qaz2wsx3edc4rfv +1qaz2wsx3edc4rfv5tgb +1qazxsw23edc +1qazxsw23edcvfr4 +1urkilbth674 +1z2x3c4v5b6n +222222222222 +2gether4ever +30secondstomars +31p5wtdyg/wgq +32615948worms +37gudoplfs45 +44442013vkkv +4815162342lost +5.254.105.20:test +5.254.105.20:test1 +520love101182 +5532361cnjqrf +555555555555 +555555555555555 +595490067ua1 +5qwerty67890 +654321123456 +666666666666 +6666666666empulgara +6dnwch275049 +741085209630 +742617000027 +753951852456 +777777777777 +777dashuta157 +79621601378.kirill +8-9899578642 +827ccb0eea8a706c4c +830928urodziny +852456852456 +858877108aop +8602229096klo +89058895869cth +89173371487v +89216279708a +9121318barssuki +9508402243id +951753852456 +9749676621ok +987654321123456789 +999999999999 +a1a2a3a4a5a6 +a1b2c3d4e5f6 +a1s2d3f4g5h6 +aaaaaaaaaaaa +aaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaa +abc123456789 +abc123abc123 +abc123def456 +abcdef123456 +abcdefghijkl +abuse_123456_abuse +aceeva.irina +administrator +ahmed_orudzhov +akkolesnikov +akucintakamu +akusayangkamu +alejandro123 +aleks-270379 +aleksandr.ustinov.2012 +aleksej.shorin.86 +alena_plotnikova_1995 +alexander123 +alexdelpiero +alhamdulillah +alija-gatina0 +aliya.mutallapova +allahisgreat +alleniverson +almaz_kiramov +alteclansing +amazinggrace +amerikanblend +ametistfatal +amoremiotiamo +anastasija3010 +andrey-1983_08 +andrey.shalanov +andrey1412ua +andreyka.gorshkov.98 +andreyka.zhilin.1981 +andruhova.1982 +anna.savickaya.1986 +antonichmeli +antsiferova1973 +antusenok_zhekonya +apalkova.tatiana +aqwleeb_6e0pk59 +arabella24630 +architecture +arina-sharapova-80 +arividerchi_shatalov +armyach-aleksandra +arquitectura +arsik-di-noxcho95 +arturamirov89 +asabsmelik5g +asd123456789 +asd123asd123 +asdasd123123 +asdasd12313d +asdasdasd123 +asdasdasdasd +asdfasdfasdf +asdfgh123456 +asdfghjkl;' +asdfghjkl:': +asdfghjkl123 +asdfghjkl12345 +asdfghjkl456 +asdfghjklzxcvbnm +asdfqwer1234 +ashishbiyani +asiamarketing +asil.dovlatov.1982 +assassinscreed +avadakedavra +avengedsevenfold +avetisyanartur +avrillavigne +azefirov.inno1987.r +azerty123456 +azertyuiop123 +aztyvl_ka44ze +backstreetboys +bad_boys_adience +bakuman.manga.drawing +barcelona123 +basketball10 +basketball11 +basketball12 +basketball123 +basketball13 +basketball22 +basketball23 +battlefield2 +bazueva.1990 +beloved_a_devil +besiktas1903 +billgeitslox +biochemistry +biotechnology +birthvillage +bismillah123 +bismillah786 +blackandwhite +blackangel09021 +blackpanther +blacksabbath +blahblahblah +blckd_sa_unpaidfee_oct06 +bobthebuilder +bolatov.almaz +bolshoj_zmej +bootylicious +britneyspears +bulka-a-shah +butterfinger +butterfly123 +butterscotch +californication +ceckbrcerfkbxyjcnm2 +charliebrown +cheekymonkey +cheerleading +cheeseburger +chesterfield +chocolate123 +christian123 +christopher1 +ciaociaociao +cjdthitycndj +cod12qw75rqyi59n +combat123654 +communication +confidential +constance626boilard1987 +construction +cookiemonster +cosmopolitan +counterstrike +cradleoffilth +cristianoronaldo +crosscountry +d1bd6bc58c1d74df41a957489c9942f5 +d41d8cd98f00 +d41d8cd98f00b204e980 +dallascowboys +damira.shagabutdinova +dancingqueen +davidbeckham +davidstrokov +deepfrequency +default_password +delirium9111 +demon1q2w3e4r +demon1q2w3e4r5t +denis_nazarenko +denya2531914 +determination +devilmaycry4 +dfczcghjcbnm +dfg5fhg5vgfh1 +dianochka1924 +dima199219921 +dimafilippov +dimitr692010 +dimulya.vasilenko +dkflbvbhjdbx +dkflbvbhjdyf +dmitrii-skargo +dmitrij.mironov2014 +dolgushina.76 +doomsayer.2.7mords.v +doomsayer.2.7mords.vv +dovbeshkosushova.1972 +dragonballgt +dragonballz1 +dragonmaster +dragonslayer +dreamcatcher +dreamtheater +drm199019902323 +drozdovaksusha +e-eremeeva1976 +e10adc3949ba59abbe56 +eanovozhilov +easytocrack1 +edwardcullen +efimkin_igor +egor.vasilin +egorov_maxim +elcubano1893a +elenanesterova +elizabeth123 +elvira198927 +elvispresley +encyclopedia +entertainment +eremei_vasechkin +ernestsantikov +etravelmoleoptin +evgenii-shenderovich +evial_marina +ezekiel11989 +ezekiel11991 +fallen_angel +familiyafamiliya +fenerbahce1907 +fenohasina39 +fernandotorres +ffffffffffff +fggjkbyfhbq007 +fgjrfkbgcbc34 +fiammalex1@hotmail.it +filatovaev1981 +finalfantasy +finalfantasy7 +financial123 +fishbaracuda +fisioterapia +fjodorova-natashenka +fkmnthyfnbdf +fktrcfylhjdbx +fktrcfylhjdyf +flvbybcnhfnjh +foreveralone +foreveryoung +frankenstein +friendofarriane +friendofearning$1 +friendofemily +friendofeveryemailyouproc +friendofgerly +friendofjoan +friendofthenext18peoplew +friendofyoucanmake$200- +friends4ever +friendsforever +fuckfuckfuck +fucktheworld +fuckthisshit +fuckyoubitch +fyujk.fyukf.87 +g00dpa$$w0rd +g13916055158 +gaar_vitalik73 +gadjieva.gulmira +galatasaray1905 +galina-davidyuk +galinka_korneeva +gatina_albina +gayassfagpastebinleaks +gettherefast +gfhjkmgfhjkm +ghalina_1971 +ghbdtnghbdtn +ghbdtnrfrltkf +ghhh47hj7649 +ghjcnjgfhjkm +ghjcnjghjcnj +ghjcnjqgfhjkm +ghjcnjrdfibyj +ghrimachiova64 +ghusieva1958 +gidrometeoburo +glafira110169 +goderdzikarxjxv +gofuckyourself +golovizina_elena +golubinskij.87 +goluboglazka08 +goodcharlotte +googlecheckout +googletester +grandchildren +greenlantern +gtasanandreas +guldaniya.galina +gv5235523532 +gvinpinka123 +gygypyfyyyposhy +hakunamatata +hallucinationse +hamulakvitaly +hannahmontana +happybirthday +happynewyear +harleydavidson +harrypotter1 +hastalavista +haveaniceday +hawkesbury93 +hd764nw5d7e1vb1 +heartbreaker +hedimaptfcorp +heitor250493 +helga_557634 +hellogoodbye +hellokitty123 +hermina617berno1990f6i +heterosexual +holidaysecure123 +holidaysecure123$ +homersimpson +homesweethome +howareyou123 +htt//members.cumfiesta.com/ +huangfeisuny +huangjin1987 +hugo854lataille1988 +i234i234i234 +ichliebedich +ifuckyou1987 +ignatova_1978_09 +igorek-filatov +ihatethisgame +ijfrnhf7yhcy54bhy0cd +ilovemyfamily +ilovemyindia +ilovemymother +ilovethisgame +iloveyou1234 +iloveyou4ever +iloveyoubaby +iloveyousomuch +imyaimyaimya +inactive1996aug +independence +independiente +indianajones +informatique +ingodwetrust +internacional +international +internazionale +iseedeadpeople +islcollective +ivankaterinchenko +ivanovamotya +jacksonville +jafjkshf7y6w34rjd +jamesbond007 +jannaarhipova +jaw138whet330 +jeremiah2911 +jesuschrist1 +jesuslovesme +jh5thrwgefsdfs +jkiuztdftl57 +john!20130605at1753 +jonasbrothers +julia_eduardovna +juliadronina1996 +jundian2011xr +justinbieber +kafedra_oisp +kamazist.tsobenko +kartoffelpuffer +khuljasimsim +kikugalanetroot +kindergarten +kingdomhearts +kingdomhearts2 +kinyabuzova.eleonora +kirienko_svetlana +kirik26trimyasov +kirill.kirillov.2013 +kirill999_97 +knackwurst8853 +kobebryant24 +komltptfcorp +konfetina-kis +korostelev_3333 +kotikova_n_m +kotovichvalentina +kotya_bagdan +kovalevagn2057 +kovalienko63 +kovalskaya-t +kovshikova1981 +ksyusha.kulagina.91 +kuchierova1994 +kudielia.anna +kudrjashova62 +kudryavcevavalya +kurmangazieva_gulmira +kuznecovviktorr +l33tsupah4x0r +lady.procenko71 +laurel12creek +lavidaesbella +lavieestbelle +leavemealone +lebronjames23 +leliane50934 +len_rin_kagamine_02 +levenyatko2007 +lexaafanasiev84 +lhbjkjubz2957704 +lightpower12345 +linkedin.com +linkedin1234 +linkedin2010 +linkedin2011 +linkedin2012 +linkedinlinkedin +linkedinpass +linkedinpassword +listopad.iuliia +livelaughlove +liverpool123 +liverpool1892 +lobkova.1979 +lol123456789 +lookingforlove +loop1206ssdsff +lordoftherings +lost4815162342 +louisvuitton +love123456789 +love777321777 +loveandpeace +lovelovelove +lpz93ssskqw8q +lulu889jdddd +lydcc20091314 +m01759766727 +macegorova.mariya +magma9824660 +mahmudali1987 +maitland571ka1994 +majiajun8888 +maks.abramov.99.99 +maksim.bychkov.1986 +maksimilian_nasirov +malhotra493ozzy1991282151 +malina_bratsk +mamanjetaime +manchester123 +manchesterunited +mandds1mg6bv8re +manovitskaya +manuliktatyana +maprchem56458 +marcoantonio +marek14michal +maria-demidchik +mariaeduarda +mariafernanda +mariagoncharenko +marielwfledlow +marijuana420 +marilynmanson +marina_stryazhkova +marina.shapovalo +marina.ushakovaznuv +marinadeduxinaa +marinamarina +marinaorlova1991 +marinochka.zhelannaya +masloboinikova1987 +massimiliano +masterblaster +matrassesotk +matveev792010 +max.kirilov.90 +maximova_elena77 +mbtvibranike +mediterraneo +megaparol12345 +memyselfandi +mercedesbenz +metallica123 +metallica666 +michaeljackson +michelangelo +microbiology +migrationsandeep +migrationschool +mihail_alekseevskiy406 +mihail-saratov +mihailpezhemskii +mikhailova-w +mikhayildopy +milankasanakoeva +milanova-kira +milay-ven2011 +milena699803 +mimamamemima +minecraft123 +mirellabroersma1987570 +misch.sosnin +misericordia +mishanna1936 +miss-evgeniya-93 +miss.marina.nikolaeva.2013 +mississippi1 +mityukova.anya +mknaxhxeh8yp3tf +mnenrad6983616 +mod7tygrysow +moderncombat +modernwarfare +modernwarfare2 +mogychajagopa +mojurus5575566 +molchanova_tlt +monaliza2786 +morozovavalya28 +mortalkombat +moscowcallin +motherfucker +motherfucker1 +motrya.larina +mozellbranou81e +mutalim.gusenov +mybossmyhero1 +mychemicalromance +mynameiskhan +mypassphrase +n8zgt5p0shw= +narashchivaiunoghti +narutouzumaki +nastenabendel +nastina33592 +nastyaanciferova +nastyanastya +natalya.dmitrieva.1978 +natasha_19.65 +nati.beridze.92 +natiichen999 +necronomicon +needforspeed +nemochkao1975 +never_desponding +neversaynever +nevertarget7 +newfoundland +neworleans12345 +nguyen4thewin +nightcrawler +nikolay_thebest +njhygtftb567 +nmt89109328673 +oksana130279 +oksanaorgadykova +olechka061187 +olesya.kolchina +olga.kazakova_85 +olga.lekarewa +olga.ponomarenko.2012 +olga.rumyanceva.1985 +olgastrashney +olgha.pietrova.1979 +olqa_motosova +omnamahshivay +onedirection +opelastra123 +optimusprime +oriflame_1910 +orlandobloom +over2yangshuo +owner@hr.com +pakistan12345 +pakistan1947 +panathinaikos +paralelepipedo +password@123 +password1234 +password12345 +password123456 +password1234567 +password12345678 +password123456789 +password2010 +password2011 +passwordpassword +pe#5gz29ptzmse +peaceandlove +peanutbutter +philadelphia +pincopallino +plankova2012 +playstation1 +playstation2 +playstation3 +pletnevakaterina +pokemon12345 +polikloh00000 +polniypizdec0211 +polniypizdec1102 +polniypizdec110211 +popokatepetl +poseinfopass +powerrangers +praisethelord +prettyinpink +professional +professionaltools +profissional +projectsadminx +prosto_chelkynchik +prozukin-shift +przyjaciolki +ptybnxtvgbjy +punksnotdead +pyanzina_elena +q18lg49iq8bhu +q1w2e3r4t5y6 +q1w2e3r4t5y6u7 +q1w2e3r4t5y6u7i8 +q1w2e3r4t5y6u7i8o9 +q1w2e3r4t5y6u7i8o9p0 +qa27111985qa +qawsedrftgyh +qaz123456789 +qaz123wsx456 +qazwsx123456 +qazwsxedc123 +qazwsxedc12345 +qazwsxedcrfv +qazwsxedcrfvtgb +qazwsxqazwsx +qazxswedc123 +qazxswedcvfr +qqqqqqqqqqqq +queteimporta +qwaszxerdfcv +qwaszxqwaszx +qwe123456789 +qwe123qwe123 +qwe123rty456 +qweasdqweasd +qweasdzxc123 +qwegta13091990 +qwepoiasdlkj +qweqwe123123 +qwerasdf1234 +qwerasdfzxcv +qwertasdfgzxcvb +qwerty112233 +qwerty123321 +qwerty123456 +qwerty1234567 +qwerty12345678 +qwerty123456789 +qwerty1234567890 +qwerty654321 +qwerty777888 +qwertyasdfgh +qwertykolakola +qwertyqwerty +qwertyuiop[] +qwertyuiop12 +qwertyuiop123 +qwertyuiop1234 +qwertyuiop12345 +qwertyuiop123456 +qwertyuiop123456789 +qwertyuiop1234567890 +qwertyuiop789 +qwertyuiopasdfg +qwertyuiopasdfghjkl +qwertyuiopasdfghjkl151515 +qwertyuiopasdfghjklzxcvbnm +qwertyytrewq +r6a50de3ujwtog4 +rachelle289ariyoshi5251987 +radhakrishna +radhekrishna +radion-dankov +rahmudinov92 +raisa_smelkova +ramis_bairamov +rashaun966krager1993 +raskevichtanja +raymonde336schwegel7331987 +refillmotives +regawf7ss1dm7rn +regina-rebina +registration +relationship +rena.vano1990 +rendakova_sveta +renesmeecallen +residentevil +residentevil4 +resistant50m +riodejaneiro +rita_nizhnik_r +riwr2hky4w3tjgg +rkamkin.karoli1988mp +rogovsasha123 +rollercoaster +rollingstones +romanova.natalya.96 +ronaldinho10 +rtertuy77ijyhu7i +runescape123 +s8ylpe9jdpvym +s9qxa9yn9cc= +safonova.1967 +safronov_3112 +sagopakajmer +sakoshka_masha +sanfrancisco +santodomingo +sasukeuchiha +sataieva.elinka +satisfaction +schmetterling +sdf7asdf6asdg8df +sdf7asdf6asdg8df1 +sebastian106 +sebastian123 +semina-alina +seniseviyoru +seniseviyorum +seo21saafd23 +serega_torov +seregakalygin +sergeevna_10.10.91 +sergei1986inna +sergej_de_sad +sergey_brrr24 +sergey-ivanov +sergey-personal +sergey.lushnikoff +sergey.melnik-1996 +sergio-emperior +serikpaevna_92 +sevgeyq9v7kor +sexonthebeach +shahrukhkhan +shakmakovataisiya1992 +shaquillesecx10 +sharmaine005foskett6071988 +shea019bernabei1992 +sheba417yorck1986vxm7 +shevcov_alesha +shiningeagle +shirankova42 +shirshov.1968 +shishmarev76 +shootingstar +showmethemoney +shukshinasveta +shukurova-ismigu +siiifonjiknalivai +silakova.nadezhda.2012 +silantyi1987 +silversurfer +simonova_lyudmila_73 +simonova5570 +simonovskiy1970 +sirazhdinov.shamil +sivenkovklin +skateboarding +skorpions23.5 +skrip-natalya +slava_kulbidyuk +slava.grinco +slavik200887 +slipchenko2004 +slonenok1009 +snowboarding +sodikov.talat +sofya.kulikova.87 +sogevigdil1981 +sojdlg123aljg +solomaha-denis +soloveibormalei +somanypickles27 +sonnenschein +sonyericsson +sorokina-999-01 +sorokina7778 +spiderman123 +spiridonmarkin1982 +splintercell +spongebob123 +spring938burston0001990 +srcuqq1_2j1h3rbf +sss-nastya-sss +ssssssssssss +stalker_lemurrr +startfinding +starwars03ja +starwarsfan10 +stas_the_best.ru +stas.gorodissky +stefancelmare +stefangreil1983 +stella354926 +stepanov_georgij +stone_mitich +strange.lena +stratocaster +stratovarius +strawberries +streetfighter +stsnatasha2008 +sulamifcherenchikova1979 +summer.fruit +sungatullinad +supernatural +sutenm123456 +svetik_kryuchkov +svetlana-mironova-13 +svetlana26rus +swaminarayan +swamisamarth +systemofadown +t_v_belyakova +tagesgruppe2010 +telechargement +tema-bushelev +tema.barabin +tempesth1941 +temppassword +tequieromucho +thecrazymaxim +thelordisgood +thereisnospoon +thisismypassword +thunderstorm +tielandros01 +tinkerbell12 +tivogliobene +tkgsoo083bsr +tobeornottobe +tolstyh.oxana +total12scherz +transformers +travelmoleoptin +tundra_cool2 +tygferrfddss +typetogether +tyumchenko_ok +ukflbfnjh12345 +unitedkingdom +unitedstates +universitario +uzumakinaruto +vadyusha.isaev.83 +valentina.victoria +valentinorossi +valya-garanina +valya-sidenko +valya-valya-1982 +valya.gorodn +valya.klimenko.87 +valya.korobeynik +valya.kryuchkova.1990 +valya.lantux.83 +valya.shapoval +vanessa141175 +varduhigohar +varfolomeeva0990 +varfolomey_72 +varich-masha +varida.alibaeva +vashenko.u.u +venividivici +veralipatnikova +vesproongh12 +vfvfvskfhfve +victor_evdokimov +victor_sakhalin1990-20 +victorclavier +victoria_002 +victoria_shkurco +victoria-berkat-energo +victoria-dyatlova +victoria.20000 +victoria.kl106 +victoria14.09 +victoria392eliezrie1992frp +victoriandr01 +vicusa.tatyana +vihlaeva2012 +vihotsieva94 +vika-selfish +vika.krivonos.1995 +vitor1268123 +vkontakte.ru +vkovshut_annab1988fg +vks1zz9v_7ceu69 +vladcherktecktonik +vladimirovna +volfram.kozlov123633 +volfram.kozlov173902 +volgograd.orlovka +voliahim1891 +voljanka1976 +volkova.elena.13 +volkova.ksyusha.89 +volkovanata77 +volkovaolguna +volkovoi_net +volodina.elena66 +volodya-ivanov-63 +volohovich-t +volokitina.olga +voroshilova.liliya +vtuf36jhufpv +wanshuai198202 +warhammer40k +webmaster123 +welcome12345 +werderbremen +weronika1992 +whosyourdaddy +whysoserious +windowsvista +winniethepooh +winnipeg2612 +witspass1234 +wocao5201314 +worldofwarcraft +woshishei319 +wrestlemania +wwwksenechkawww +xalitova1988 +xantria10315 +xenosaga1234 +xtseo2011tdx +xxxxxxxxxxxx +yana.kolomoets +yanchikchmoki +yandi20080527 +yegbpltw2012lol +yemi19900911 +yfdbufnjh10305070 +yulia_yulia_13 +yulia-dubrovina +yulyasik2002 +yushinazena2009 +z1x2c3v4b5n6 +z1x2c3v4b5n6m7 +zabelina12391 +zabelinaalena +zabihullin99 +zabloczkaya2012 +zabolotneva_ira +zabolotniy.a +zabolotnov1968 +zabolotnova31337 +zabotin_sasha +zaq12wsxcde3 +zaq1xsw2cde3 +zaqwsxcderfv +zaqxswcde123 +zaqxswcdevfr +zarina_loves +zarina_sabirova +zarina_zhasan +zarinaismailova +zarinochka_b +zaxarov-1976 +zcfvfzkexifz +zcfvfzrhfcbdfz +zenit-talnah +zimmermann33 +zmx870919123 +zxc123456789 +zxcasdqwe123 +zxcvbn123456 +zxcvbnm12345 +zxcvbnm123456 +zxcvbnm1234567 +zxcvbnm123456789 +zxcvbnmasdfghjkl +zxcvbnmmnbvcxz +zxcvbnmzxcvbnm diff --git a/internal/auth/encrypt.go b/internal/auth/encrypt.go new file mode 100644 index 0000000..b205e8f --- /dev/null +++ b/internal/auth/encrypt.go @@ -0,0 +1,109 @@ +package auth + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + + "github.com/aws/aws-sdk-go-v2/service/kms" +) + +// Encrypter protects secrets the server must be able to read back: TOTP +// secrets and webhook signing secrets (NFR → Secrets at rest). KMS in +// production; AES-256-GCM under a local key elsewhere. +type Encrypter interface { + Encrypt(ctx context.Context, plaintext string) (string, error) + Decrypt(ctx context.Context, ciphertext string) (string, error) +} + +// LocalEncrypter is AES-256-GCM. Ciphertext is "l1:" + base64(nonce|sealed). +type LocalEncrypter struct{ aead cipher.AEAD } + +// NewLocalEncrypter uses a base64 32-byte key, or derives a fixed +// development key when key is empty (never valid in production; config +// refuses to start production without KMS). +func NewLocalEncrypter(key string) (*LocalEncrypter, error) { + var k []byte + if key == "" { + sum := sha256.Sum256([]byte("substratal-local-development-data-key")) + k = sum[:] + } else { + var err error + if k, err = base64.StdEncoding.DecodeString(key); err != nil || len(k) != 32 { + return nil, fmt.Errorf("auth: LOCAL_DATA_KEY must be base64 of 32 bytes") + } + } + block, err := aes.NewCipher(k) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &LocalEncrypter{aead: aead}, nil +} + +func (e *LocalEncrypter) Encrypt(_ context.Context, plaintext string) (string, error) { + nonce := make([]byte, e.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + sealed := e.aead.Seal(nonce, nonce, []byte(plaintext), nil) + return "l1:" + base64.StdEncoding.EncodeToString(sealed), nil +} + +func (e *LocalEncrypter) Decrypt(_ context.Context, ciphertext string) (string, error) { + if len(ciphertext) < 3 || ciphertext[:3] != "l1:" { + return "", errors.New("auth: not a local ciphertext") + } + raw, err := base64.StdEncoding.DecodeString(ciphertext[3:]) + if err != nil || len(raw) < e.aead.NonceSize() { + return "", errors.New("auth: malformed ciphertext") + } + pt, err := e.aead.Open(nil, raw[:e.aead.NonceSize()], raw[e.aead.NonceSize():], nil) + if err != nil { + return "", err + } + return string(pt), nil +} + +// KMSEncrypter encrypts directly with a symmetric KMS key. Every secret it +// protects is far below KMS's 4 KB direct-encryption limit. +type KMSEncrypter struct { + client *kms.Client + keyID string +} + +// NewKMSEncrypter returns a KMS-backed Encrypter. +func NewKMSEncrypter(client *kms.Client, keyID string) *KMSEncrypter { + return &KMSEncrypter{client: client, keyID: keyID} +} + +func (e *KMSEncrypter) Encrypt(ctx context.Context, plaintext string) (string, error) { + out, err := e.client.Encrypt(ctx, &kms.EncryptInput{KeyId: &e.keyID, Plaintext: []byte(plaintext)}) + if err != nil { + return "", err + } + return "k1:" + base64.StdEncoding.EncodeToString(out.CiphertextBlob), nil +} + +func (e *KMSEncrypter) Decrypt(ctx context.Context, ciphertext string) (string, error) { + if len(ciphertext) < 3 || ciphertext[:3] != "k1:" { + return "", errors.New("auth: not a KMS ciphertext") + } + blob, err := base64.StdEncoding.DecodeString(ciphertext[3:]) + if err != nil { + return "", err + } + out, err := e.client.Decrypt(ctx, &kms.DecryptInput{KeyId: &e.keyID, CiphertextBlob: blob}) + if err != nil { + return "", err + } + return string(out.Plaintext), nil +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..2932b54 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,232 @@ +package auth + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math/big" + "os" + "path/filepath" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/service/kms" + kmstypes "github.com/aws/aws-sdk-go-v2/service/kms/types" +) + +// Signer produces RS256 signatures. Production signs with KMS (the private +// key never leaves it, Auth → Signing keys); local and tests use a PEM key. +type Signer interface { + // KeyID is the kid of the key that signs new tokens. + KeyID() string + // SignDigest signs a SHA-256 digest with RSASSA-PKCS1-v1_5. + SignDigest(ctx context.Context, digest []byte) ([]byte, error) + // PublicKeys are every key to publish in the JWKS and accept when + // verifying: the active one plus any being rotated in or out. + PublicKeys() map[string]*rsa.PublicKey +} + +// KeyIDFor derives a stable kid from a public key. +func KeyIDFor(pub *rsa.PublicKey) string { + der, _ := x509.MarshalPKIXPublicKey(pub) + sum := sha256.Sum256(der) + return "sk_" + hex.EncodeToString(sum[:6]) +} + +// LocalSigner signs with an in-process RSA key. +type LocalSigner struct { + key *rsa.PrivateKey + kid string +} + +// NewLocalSigner loads the PEM key at path, generating it on first use. +func NewLocalSigner(path string) (*LocalSigner, error) { + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + key, gerr := rsa.GenerateKey(rand.Reader, 2048) + if gerr != nil { + return nil, gerr + } + b = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + if err := os.WriteFile(path, b, 0o600); err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + block, _ := pem.Decode(b) + if block == nil { + return nil, fmt.Errorf("auth: %s isn't a PEM key", path) + } + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + return NewLocalSignerFromKey(key), nil +} + +// NewLocalSignerFromKey wraps an existing key (tests). +func NewLocalSignerFromKey(key *rsa.PrivateKey) *LocalSigner { + return &LocalSigner{key: key, kid: KeyIDFor(&key.PublicKey)} +} + +func (s *LocalSigner) KeyID() string { return s.kid } + +func (s *LocalSigner) SignDigest(_ context.Context, digest []byte) ([]byte, error) { + return rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA256, digest) +} + +func (s *LocalSigner) PublicKeys() map[string]*rsa.PublicKey { + return map[string]*rsa.PublicKey{s.kid: &s.key.PublicKey} +} + +// KMSSigner signs with an asymmetric RSA_2048 KMS key. +type KMSSigner struct { + client *kms.Client + keyID string // KMS key id/ARN that signs + kid string + pubs map[string]*rsa.PublicKey +} + +// NewKMSSigner fetches the public halves of the active key and of every +// additionally published key (rotation, Auth → Signing keys) once, at cold +// start, so verification never calls KMS. +func NewKMSSigner(ctx context.Context, client *kms.Client, active string, published []string) (*KMSSigner, error) { + s := &KMSSigner{client: client, keyID: active, pubs: map[string]*rsa.PublicKey{}} + for i, id := range append([]string{active}, published...) { + out, err := client.GetPublicKey(ctx, &kms.GetPublicKeyInput{KeyId: &id}) + if err != nil { + return nil, fmt.Errorf("auth: kms public key %s: %w", id, err) + } + pk, err := x509.ParsePKIXPublicKey(out.PublicKey) + if err != nil { + return nil, err + } + rpk, ok := pk.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("auth: kms key %s isn't RSA", id) + } + kid := KeyIDFor(rpk) + s.pubs[kid] = rpk + if i == 0 { + s.kid = kid + } + } + return s, nil +} + +func (s *KMSSigner) KeyID() string { return s.kid } + +func (s *KMSSigner) SignDigest(ctx context.Context, digest []byte) ([]byte, error) { + out, err := s.client.Sign(ctx, &kms.SignInput{ + KeyId: &s.keyID, + Message: digest, + MessageType: kmstypes.MessageTypeDigest, + SigningAlgorithm: kmstypes.SigningAlgorithmSpecRsassaPkcs1V15Sha256, + }) + if err != nil { + return nil, err + } + return out.Signature, nil +} + +func (s *KMSSigner) PublicKeys() map[string]*rsa.PublicKey { return s.pubs } + +var b64 = base64.RawURLEncoding + +// SignJWT encodes and signs claims as an RS256 JWT. +func SignJWT(ctx context.Context, s Signer, claims any) (string, error) { + header, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT", "kid": s.KeyID()}) + body, err := json.Marshal(claims) + if err != nil { + return "", err + } + signingInput := b64.EncodeToString(header) + "." + b64.EncodeToString(body) + digest := sha256.Sum256([]byte(signingInput)) + sig, err := s.SignDigest(ctx, digest[:]) + if err != nil { + return "", err + } + return signingInput + "." + b64.EncodeToString(sig), nil +} + +// ErrInvalidToken is any verification failure; callers return 401. +var ErrInvalidToken = errors.New("auth: invalid token") + +// VerifyJWT checks an RS256 JWT's signature against keys and decodes its +// claims into dst. Callers check iss/aud/exp on the decoded claims. +func VerifyJWT(token string, keys map[string]*rsa.PublicKey, dst any) error { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return ErrInvalidToken + } + hb, err := b64.DecodeString(parts[0]) + if err != nil { + return ErrInvalidToken + } + var h struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + } + if json.Unmarshal(hb, &h) != nil || h.Alg != "RS256" { + return ErrInvalidToken + } + pub, ok := keys[h.Kid] + if !ok { + return ErrInvalidToken + } + sig, err := b64.DecodeString(parts[2]) + if err != nil { + return ErrInvalidToken + } + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if rsa.VerifyPKCS1v15(pub, crypto.SHA256, digest[:], sig) != nil { + return ErrInvalidToken + } + body, err := b64.DecodeString(parts[1]) + if err != nil { + return ErrInvalidToken + } + if json.Unmarshal(body, dst) != nil { + return ErrInvalidToken + } + return nil +} + +// JWK is one published key. +type JWK struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Use string `json:"use"` + Alg string `json:"alg"` + N string `json:"n"` + E string `json:"e"` +} + +// JWKS renders keys as a JSON Web Key Set. +func JWKS(keys map[string]*rsa.PublicKey) map[string][]JWK { + out := make([]JWK, 0, len(keys)) + for kid, k := range keys { + out = append(out, JWK{ + Kty: "RSA", Kid: kid, Use: "sig", Alg: "RS256", + N: b64.EncodeToString(k.N.Bytes()), + E: b64.EncodeToString(big.NewInt(int64(k.E)).Bytes()), + }) + } + return map[string][]JWK{"keys": out} +} + +// Now is overridable in tests. +var Now = time.Now diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..e2700a0 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,132 @@ +// Package auth holds the credential primitives behind API Reference → Auth: +// password hashing and policy, TOTP, recovery codes, and JWT signing. +package auth + +import ( + "bufio" + "crypto/rand" + "crypto/subtle" + _ "embed" + "encoding/base64" + "errors" + "fmt" + "strings" + "sync" + "unicode/utf8" + + "golang.org/x/crypto/argon2" +) + +// Argon2id parameters from NFR → Authentication: m=19456 KiB, t=2, p=1. +const ( + argonMemory = 19456 + argonTime = 2 + argonThreads = 1 + argonKeyLen = 32 + argonSaltLen = 16 +) + +// HashPassword returns a PHC-format Argon2id hash. +func HashPassword(password string) (string, error) { + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil +} + +// VerifyPassword checks password against a PHC Argon2id hash in constant time. +func VerifyPassword(password, encoded string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false + } + var m, t uint32 + var p uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// dummyHash is verified against when an account doesn't exist, so a login +// for an unknown email costs the same as a wrong password (no enumeration +// by timing). +var ( + dummyOnce sync.Once + dummyHash string +) + +// BurnPasswordCheck spends one Argon2id verification on nothing. +func BurnPasswordCheck(password string) { + dummyOnce.Do(func() { dummyHash, _ = HashPassword("timing-equalizer-password") }) + VerifyPassword(password, dummyHash) +} + +//go:embed breached.txt +var breachedList string + +var ( + breachedOnce sync.Once + breached map[string]struct{} +) + +func isBreached(password string) bool { + breachedOnce.Do(func() { + breached = map[string]struct{}{} + sc := bufio.NewScanner(strings.NewReader(breachedList)) + for sc.Scan() { + line := sc.Text() + if line == "" || strings.HasPrefix(line, "# ") { + continue + } + breached[line] = struct{}{} + } + }) + _, ok := breached[strings.ToLower(password)] + return ok +} + +// ErrWeakPassword carries the policy reason: too_short, too_long, breached, +// or matches_email. +type ErrWeakPassword struct{ Reason string } + +func (e *ErrWeakPassword) Error() string { return "weak password: " + e.Reason } + +// CheckPolicy applies the password policy (Auth → signup): 12–128 +// characters, not in the breached list, not equal to the email. No +// composition rules (NIST SP 800-63B). +func CheckPolicy(password, email string) error { + n := utf8.RuneCountInString(password) + switch { + case n < 12: + return &ErrWeakPassword{"too_short"} + case n > 128: + return &ErrWeakPassword{"too_long"} + case strings.EqualFold(strings.TrimSpace(password), strings.TrimSpace(email)): + return &ErrWeakPassword{"matches_email"} + case isBreached(password): + return &ErrWeakPassword{"breached"} + } + return nil +} + +// WeakReason extracts the policy reason from err, if it is one. +func WeakReason(err error) (string, bool) { + var w *ErrWeakPassword + if errors.As(err, &w) { + return w.Reason, true + } + return "", false +} diff --git a/internal/auth/totp.go b/internal/auth/totp.go new file mode 100644 index 0000000..1bec47a --- /dev/null +++ b/internal/auth/totp.go @@ -0,0 +1,113 @@ +package auth + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" //nolint:gosec // RFC 6238 TOTP is defined over HMAC-SHA-1; authenticator apps expect it. + "encoding/base32" + "encoding/binary" + "fmt" + "net/url" + "strings" + "time" +) + +// TOTP parameters (Auth → mfa/verify): RFC 6238, SHA-1, 6 digits, 30 s +// step, ±1 step of drift. +const ( + totpStep = 30 + totpDigits = 6 + totpDrift = 1 +) + +var b32 = base32.StdEncoding.WithPadding(base32.NoPadding) + +// NewTOTPSecret returns a fresh 160-bit base32 secret. +func NewTOTPSecret() string { + b := make([]byte, 20) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return b32.EncodeToString(b) +} + +// OTPAuthURI is what the client renders as a QR code. +func OTPAuthURI(secret, email string) string { + label := url.PathEscape("Substratal:" + email) + return fmt.Sprintf("otpauth://totp/%s?secret=%s&issuer=Substratal&digits=%d&period=%d", label, secret, totpDigits, totpStep) +} + +// totpAt is the code for one time step. +func totpAt(secret string, step int64) (string, error) { + key, err := b32.DecodeString(strings.ToUpper(strings.TrimSpace(secret))) + if err != nil { + return "", err + } + var msg [8]byte + binary.BigEndian.PutUint64(msg[:], uint64(step)) + mac := hmac.New(sha1.New, key) + mac.Write(msg[:]) + sum := mac.Sum(nil) + off := sum[len(sum)-1] & 0x0f + code := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1_000_000 + return fmt.Sprintf("%06d", code), nil +} + +// TOTPCode returns the current code (tests and local tooling). +func TOTPCode(secret string, now time.Time) string { + c, _ := totpAt(secret, now.Unix()/totpStep) + return c +} + +// VerifyTOTP checks code against the window around now. It returns the +// matched step so the caller can store it and refuse reuse: a step at or +// before lastStep is rejected even when its code is correct. +func VerifyTOTP(secret, code string, now time.Time, lastStep int64) (step int64, ok bool) { + code = strings.TrimSpace(code) + if len(code) != totpDigits { + return 0, false + } + cur := now.Unix() / totpStep + for d := int64(-totpDrift); d <= totpDrift; d++ { + s := cur + d + if s <= lastStep { + continue + } + want, err := totpAt(secret, s) + if err != nil { + return 0, false + } + if hmac.Equal([]byte(want), []byte(code)) { + return s, true + } + } + return 0, false +} + +// recoveryAlphabet avoids look-alike characters. +const recoveryAlphabet = "abcdefghjkmnpqrstuvwxyz23456789" + +// NewRecoveryCodes returns n codes shaped like "7hq2-kx9m-a4vd". +func NewRecoveryCodes(n int) []string { + out := make([]string, n) + for i := range out { + b := make([]byte, 12) + if _, err := rand.Read(b); err != nil { + panic(err) + } + var sb strings.Builder + for j, c := range b { + if j > 0 && j%4 == 0 { + sb.WriteByte('-') + } + sb.WriteByte(recoveryAlphabet[int(c)%len(recoveryAlphabet)]) + } + out[i] = sb.String() + } + return out +} + +// NormalizeRecoveryCode lowercases and trims a typed recovery code. +func NormalizeRecoveryCode(c string) string { + return strings.ToLower(strings.TrimSpace(c)) +} diff --git a/internal/billing/billing.go b/internal/billing/billing.go new file mode 100644 index 0000000..d7f171b --- /dev/null +++ b/internal/billing/billing.go @@ -0,0 +1,177 @@ +// Package billing is the Stripe side of Substratal's own platform +// subscription (DEPLOYMENT.md → Stripe Billing): Customers, Team Checkout, +// the Customer Portal, the inbound webhook, subscription sync, and the +// daily seat sync. It never touches a developer's own end-user billing. +package billing + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "time" + + "github.com/stripe/stripe-go/v87" + + "github.com/Adron/substratalapps.com/internal/core" + "github.com/Adron/substratalapps.com/internal/platform/config" +) + +// Price lookup keys from Pricing → Stripe catalog. Code resolves prices +// by lookup key, never by hard-coded price id, so test and live share code. +const ( + LookupTeamBase = "team_base_monthly_usd" + LookupTeamSeats = "team_seats_monthly_usd" +) + +// Client implements core.Billing over stripe-go. +type Client struct { + sc *stripe.Client + prices config.StripePrices + log *slog.Logger +} + +var _ core.Billing = (*Client)(nil) + +// New returns a Stripe-backed Client. +func New(secretKey string, prices config.StripePrices, log *slog.Logger) *Client { + return &Client{sc: stripe.NewClient(secretKey), prices: prices, log: log} +} + +// EnsureCustomer creates the Tenant's Customer. The idempotency key makes +// the post-commit call and the retry job safe to repeat. +func (c *Client) EnsureCustomer(ctx context.Context, tenantID, email string) (string, error) { + p := &stripe.CustomerCreateParams{Metadata: map[string]string{"tenant_id": tenantID}} + if email != "" { + p.Email = stripe.String(email) + } + p.SetIdempotencyKey("customer:" + tenantID) + cust, err := c.sc.V1Customers.Create(ctx, p) + if err != nil { + return "", err + } + return cust.ID, nil +} + +// priceIDs resolves the Team prices: configured ids win, otherwise lookup keys. +func (c *Client) priceIDs(ctx context.Context) (base, seats string, err error) { + if c.prices.TeamBase != "" && c.prices.TeamSeat != "" { + return c.prices.TeamBase, c.prices.TeamSeat, nil + } + lp := &stripe.PriceListParams{LookupKeys: []*string{stripe.String(LookupTeamBase), stripe.String(LookupTeamSeats)}} + for p, err := range c.sc.V1Prices.List(ctx, lp).All(ctx) { + if err != nil { + return "", "", err + } + switch p.LookupKey { + case LookupTeamBase: + base = p.ID + case LookupTeamSeats: + seats = p.ID + } + } + if base == "" || seats == "" { + return "", "", fmt.Errorf("billing: Team prices not found; run the catalog sync script") + } + return base, seats, nil +} + +// CheckoutSession starts a Team subscription in Stripe Checkout. +func (c *Client) CheckoutSession(ctx context.Context, in core.CheckoutInput) (string, time.Time, error) { + base, seats, err := c.priceIDs(ctx) + if err != nil { + return "", time.Time{}, err + } + qty := int64(in.Seats) + if qty < 1 { + qty = 1 + } + p := &stripe.CheckoutSessionCreateParams{ + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + Customer: stripe.String(in.CustomerID), + ClientReferenceID: stripe.String(in.TenantID), + SuccessURL: stripe.String(in.SuccessURL), + CancelURL: stripe.String(in.CancelURL), + BillingAddressCollection: stripe.String("required"), + AutomaticTax: &stripe.CheckoutSessionCreateAutomaticTaxParams{Enabled: stripe.Bool(true)}, + LineItems: []*stripe.CheckoutSessionCreateLineItemParams{ + {Price: stripe.String(base), Quantity: stripe.Int64(1)}, + {Price: stripe.String(seats), Quantity: stripe.Int64(qty)}, + }, + SubscriptionData: &stripe.CheckoutSessionCreateSubscriptionDataParams{ + Metadata: map[string]string{"tenant_id": in.TenantID}, + }, + } + sess, err := c.sc.V1CheckoutSessions.Create(ctx, p) + if err != nil { + return "", time.Time{}, err + } + return sess.URL, time.Unix(sess.ExpiresAt, 0), nil +} + +// PortalSession opens the Customer Portal (configured in Stripe without +// plan switching). +func (c *Client) PortalSession(ctx context.Context, customerID, returnURL string) (string, time.Time, error) { + sess, err := c.sc.V1BillingPortalSessions.Create(ctx, &stripe.BillingPortalSessionCreateParams{ + Customer: stripe.String(customerID), ReturnURL: stripe.String(returnURL), + }) + if err != nil { + return "", time.Time{}, err + } + // Portal sessions are short-lived; Stripe doesn't return an expiry, so + // report the conservative five minutes the dashboard should assume. + return sess.URL, time.Unix(sess.Created, 0).Add(5 * time.Minute), nil +} + +// Subscription fetches a subscription's current state, the only source +// the sync trusts (out-of-order webhooks are harmless that way). +func (c *Client) Subscription(ctx context.Context, id string) (core.StripeSubscription, error) { + p := &stripe.SubscriptionRetrieveParams{} + p.AddExpand("items.data.price.product") + s, err := c.sc.V1Subscriptions.Retrieve(ctx, id, p) + if err != nil { + return core.StripeSubscription{}, err + } + out := core.StripeSubscription{ID: s.ID, Status: string(s.Status), CancelAtPeriodEnd: s.CancelAtPeriodEnd, + TenantID: s.Metadata["tenant_id"]} + if s.Items != nil { + for _, it := range s.Items.Data { + if it.CurrentPeriodStart > 0 { + ps, pe := time.Unix(it.CurrentPeriodStart, 0).UTC(), time.Unix(it.CurrentPeriodEnd, 0).UTC() + out.PeriodStart, out.PeriodEnd = &ps, &pe + } + if it.Price == nil { + continue + } + if it.Price.LookupKey == LookupTeamSeats || it.Price.ID == c.prices.TeamSeat { + out.SeatItemID = it.ID + } + if it.Price.Product != nil { + if plan := it.Price.Product.Metadata["substratal_plan"]; plan != "" { + out.Plan = plan + } + } + } + } + return out, nil +} + +// SetSeats writes the seat quantity, once per Tenant per day, with no +// proration (Pricing → Stripe catalog). +func (c *Client) SetSeats(ctx context.Context, tenantID, itemID string, seats int, day string) error { + p := &stripe.SubscriptionItemUpdateParams{Quantity: stripe.Int64(int64(seats)), + ProrationBehavior: stripe.String("none")} + p.SetIdempotencyKey("seat-sync:" + tenantID + ":" + day + ":" + strconv.Itoa(seats)) + _, err := c.sc.V1SubscriptionItems.Update(ctx, itemID, p) + return err +} + +// Event re-fetches an event from Stripe, for reprocessing one whose first +// attempt failed. +func (c *Client) Event(ctx context.Context, id string) (stripe.Event, error) { + ev, err := c.sc.V1Events.Retrieve(ctx, id, nil) + if err != nil { + return stripe.Event{}, err + } + return *ev, nil +} diff --git a/internal/billing/webhook.go b/internal/billing/webhook.go new file mode 100644 index 0000000..b77167c --- /dev/null +++ b/internal/billing/webhook.go @@ -0,0 +1,114 @@ +package billing + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "time" + + "github.com/stripe/stripe-go/v87" + "github.com/stripe/stripe-go/v87/webhook" + + "github.com/Adron/substratalapps.com/internal/core" +) + +// Webhook is POST /internal/stripe/webhook (DEPLOYMENT.md → Webhook +// handling). It isn't Bearer-authenticated: it verifies Stripe-Signature +// against the raw body. Every event id is recorded first (insert-or-skip), +// so a duplicate returns 200 immediately; processing re-fetches the +// Subscription from Stripe, so ordering never matters. +type Webhook struct { + Secret string + Stripe *Client + Core *core.Server + Log *slog.Logger +} + +func (h *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + ev, err := webhook.ConstructEventWithOptions(body, r.Header.Get("Stripe-Signature"), h.Secret, + webhook.ConstructEventOptions{Tolerance: 5 * time.Minute, IgnoreAPIVersionMismatch: true}) + if err != nil { + h.Log.Warn("stripe webhook signature rejected", "err", err) + w.WriteHeader(http.StatusBadRequest) + return + } + fresh, err := h.Core.RecordStripeEvent(r.Context(), ev.ID, string(ev.Type)) + if err != nil { + h.Log.Error("record stripe event", "event", ev.ID, "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + if fresh { + if err := h.Process(r.Context(), ev); err != nil { + // Left unprocessed (processed_at null): the retry job re-fetches + // it, and an alarm fires on events stuck unprocessed. + h.Log.Error("process stripe event", "event", ev.ID, "type", ev.Type, "err", err) + } + } + w.WriteHeader(http.StatusOK) +} + +// Process applies one Stripe event. +func (h *Webhook) Process(ctx context.Context, ev stripe.Event) error { + var subID, tenantID string + switch ev.Type { + case "checkout.session.completed": + var s stripe.CheckoutSession + if err := json.Unmarshal(ev.Data.Raw, &s); err != nil { + return err + } + tenantID = s.ClientReferenceID + if s.Subscription != nil { + subID = s.Subscription.ID + } + case "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted": + var s stripe.Subscription + if err := json.Unmarshal(ev.Data.Raw, &s); err != nil { + return err + } + subID, tenantID = s.ID, s.Metadata["tenant_id"] + case "invoice.paid", "invoice.payment_failed": + var inv struct { + Parent *struct { + SubscriptionDetails *struct { + Subscription string `json:"subscription"` + } `json:"subscription_details"` + } `json:"parent"` + Subscription string `json:"subscription"` + } + if err := json.Unmarshal(ev.Data.Raw, &inv); err != nil { + return err + } + subID = inv.Subscription + if subID == "" && inv.Parent != nil && inv.Parent.SubscriptionDetails != nil { + subID = inv.Parent.SubscriptionDetails.Subscription + } + default: + return h.Core.MarkStripeEventProcessed(ctx, ev.ID, "") + } + if subID == "" { + return h.Core.MarkStripeEventProcessed(ctx, ev.ID, tenantID) + } + sub, err := h.Stripe.Subscription(ctx, subID) + if err != nil { + return err + } + if sub.TenantID == "" { + sub.TenantID = tenantID + } + if err := h.Core.ApplyStripeSubscription(ctx, sub); err != nil { + return err + } + return h.Core.MarkStripeEventProcessed(ctx, ev.ID, sub.TenantID) +} diff --git a/internal/core/apikeys.go b/internal/core/apikeys.go new file mode 100644 index 0000000..6edaae9 --- /dev/null +++ b/internal/core/apikeys.go @@ -0,0 +1,423 @@ +package core + +import ( + "encoding/json" + "net/http" + "slices" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/api/gen" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/ids" +) + +type apiKey struct { + ID string `json:"id"` + Name string `json:"name"` + Scope string `json:"scope"` + Permissions []string `json:"permissions"` + Mode string `json:"mode"` + IntendedUse string `json:"intended_use"` + RestrictDestructive bool `json:"restrict_destructive"` + Secret string `json:"secret,omitempty"` + SecretHint string `json:"secret_hint"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedBy actorRef `json:"created_by"` + LastUsedAt *time.Time `json:"last_used_at"` + CreatedAt time.Time `json:"created_at"` + RevokedAt *time.Time `json:"revoked_at"` + version int +} + +const apiKeySQL = `select jsonb_build_object('id', k.id, 'name', k.name, 'scope', k.scope, 'permissions', k.permissions, + 'mode', k.mode, 'intended_use', k.intended_use, 'restrict_destructive', k.restrict_destructive, + 'secret_hint', k.secret_hint, 'expires_at', k.expires_at, + 'created_by', jsonb_build_object('type', k.created_by_type, 'id', k.created_by_id), + 'last_used_at', k.last_used_at, 'created_at', k.created_at, 'revoked_at', k.revoked_at, 'version_', k.version) + from api_keys k` + +func (c *Call) queryKeys(where string, args db.Args) ([]apiKey, error) { + raw, err := c.q.Query(c.ctx, apiKeySQL+" where "+where, args) + if err != nil { + return nil, err + } + out := make([]apiKey, 0, len(raw)) + for _, x := range raw { + var k struct { + apiKey + V int `json:"version_"` + } + if err := json.Unmarshal(x, &k); err != nil { + return nil, err + } + k.apiKey.version = k.V + out = append(out, k.apiKey) + } + return out, nil +} + +// canManageKeyScope: platform keys need api_keys.manage; app keys need it +// or the Application's owner. API Keys never manage API Keys (enforced by +// userOnly on every write). +func (c *Call) canManageKeyScope(scope string) (bool, error) { + if c.can("api_keys.manage") { + return true, nil + } + if scope == "platform" { + return false, nil + } + a, err := c.loadApp(scope) + if err == db.ErrNotFound { + return false, nil + } + if err != nil { + return false, err + } + return c.isAppOwner(a) +} + +func (c *Call) loadManagedKey(id string) (apiKey, error) { + ks, err := c.queryKeys("k.id = :id", db.Args{"id": id}) + if err != nil { + return apiKey{}, err + } + if len(ks) == 0 { + return apiKey{}, httpx.NotFoundResource("api_key") + } + if ok, err := c.canManageKeyScope(ks[0].Scope); err != nil { + return apiKey{}, err + } else if !ok { + return apiKey{}, httpx.NotFoundResource("api_key") + } + return ks[0], nil +} + +func secretHint(secret string) string { return "…" + secret[len(secret)-4:] } + +func newKeySecret(mode string) string { + prefix := ids.APIKeyLive + if mode == "test" { + prefix = ids.APIKeyTest + } + return ids.Secret(prefix) +} + +// validateKeyPermissions applies API Keys → App-confined permissions. +func (c *Call) validateKeyPermissions(scope string, perms []string) error { + var a *appRow + if scope != "platform" { + x, err := c.loadApp(scope) + if err == db.ErrNotFound { + var f httpx.Fields + f.Add("scope", "unknown_value") + return f.Err() + } + if err != nil { + return err + } + a = &x + } + for _, p := range perms { + switch { + case isPlatformPermission(p): + if a != nil && !slices.Contains(appConfinable, p) { + return httpx.E(422, "permission_not_grantable_to_scope", "An app-scoped key can't carry "+p+".").With("permission", p) + } + if a == nil && !c.can(p) { + return httpx.E(403, "role_escalation_forbidden", "A platform key can't carry a permission its creator doesn't hold.").With("permission", p) + } + case strings.HasPrefix(p, "app."): + declared := false + if a != nil { + for _, ap := range a.Permissions { + declared = declared || ap.Key == p + } + } + if !declared { + return httpx.E(422, "unknown_permission", "Unknown permission, or another Application's key.").With("permission", p) + } + default: + return httpx.E(422, "unknown_permission", "Unknown permission.").With("permission", p) + } + } + return nil +} + +func (s *Server) ApiKeysList(w http.ResponseWriter, r *http.Request, params gen.ApiKeysListParams) { + s.serve(w, r, opts{op: "apiKeys.list", userOnly: true}, func(c *Call) error { + where, args := []string{"true"}, db.Args{} + if !c.can("api_keys.manage") { + where = append(where, `k.scope in (select a.id from applications a where a.owner_user_id = :me + or a.owner_organization_id in (select organization_id from organization_memberships + where user_id = :me and role = 'org_admin' and status = 'active'))`) + args["me"] = c.p.UserID + } + if params.Scope != nil { + where = append(where, "k.scope = :scope") + args["scope"] = *params.Scope + } + if params.Mode != nil { + where = append(where, "k.mode = :mode") + args["mode"] = string(*params.Mode) + } + if params.IntendedUse != nil { + where = append(where, "k.intended_use = :iu") + args["iu"] = string(*params.IntendedUse) + } + if params.IncludeInactive == nil || !*params.IncludeInactive { + where = append(where, "k.revoked_at is null and (k.expires_at is null or k.expires_at > now())") + } + lim := httpx.Limit(params.Limit) + args["lim"] = int64(lim + 1) + filters := httpx.Filters(c.r) + pos, err := c.s.Cursors.Decode(deref(params.Cursor), filters) + if err != nil { + return err + } + if pos != nil { + where = append(where, "(k.created_at, k.id) < (:ps::timestamptz, :pi)") + args["ps"], args["pi"] = pos.Sort, pos.ID + } + rows, err := c.queryKeys(strings.Join(where, " and ")+" order by k.created_at desc, k.id desc limit :lim", args) + if err != nil { + return err + } + return c.OK(httpx.Paginate(c.s.Cursors, rows, lim, filters, func(k apiKey) (string, string) { + return httpx.TimeKey(k.CreatedAt), k.ID + })) + }) +} + +func (s *Server) ApiKeysCreate(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "apiKeys.create", userOnly: true, idem: idemOptional}, func(c *Call) error { + var in struct { + Name string `json:"name"` + Scope string `json:"scope"` + Permissions []string `json:"permissions"` + Mode *string `json:"mode"` + IntendedUse *string `json:"intended_use"` + RestrictDestructive *bool `json:"restrict_destructive"` + ExpiresAt *time.Time `json:"expires_at"` + } + if err := c.Decode(&in); err != nil { + return err + } + var f httpx.Fields + if in.Name == "" { + f.Add("name", "required") + } else if len([]rune(in.Name)) > 100 { + f.Max("name", "too_long", 100) + } + if in.Scope == "" { + f.Add("scope", "required") + } + if in.Permissions == nil { + f.Add("permissions", "required") + } + mode := "live" + if in.Mode != nil { + mode = *in.Mode + if mode != "live" && mode != "test" { + f.Enum("mode", "live", "test") + } + } + use := "service" + if in.IntendedUse != nil { + use = *in.IntendedUse + if use != "service" && use != "agent" { + f.Enum("intended_use", "service", "agent") + } + } + if in.ExpiresAt != nil && !in.ExpiresAt.After(c.now) { + f.Add("expires_at", "out_of_range").Message = "must be in the future" + } + if err := f.Err(); err != nil { + return err + } + if ok, err := c.canManageKeyScope(in.Scope); err != nil { + return err + } else if !ok { + return httpx.Forbidden("api_keys.manage") + } + perms := dedupe(in.Permissions) + if err := c.validateKeyPermissions(in.Scope, perms); err != nil { + return err + } + restrict := use == "agent" + if in.RestrictDestructive != nil { + restrict = *in.RestrictDestructive + } + secret := newKeySecret(mode) + id := ids.New(ids.APIKey) + if _, err := c.q.Exec(c.ctx, `insert into api_keys (id, name, scope, permissions, mode, intended_use, + restrict_destructive, secret_hash, secret_hint, expires_at, created_by_type, created_by_id, test_mode) + values (:id, :n, :s, :p::text[], :m, :iu, :rd, :h, :hint, :exp::timestamptz, 'user', :by, :tm)`, + db.Args{"id": id, "n": in.Name, "s": in.Scope, "p": db.TextArray(perms), "m": mode, "iu": use, "rd": restrict, + "h": ids.Hash(secret), "hint": secretHint(secret), "exp": db.NullTime(in.ExpiresAt), "by": c.p.UserID, + "tm": mode == "test"}); err != nil { + return err + } + ks, err := c.queryKeys("k.id = :id", db.Args{"id": id}) + if err != nil { + return err + } + k := ks[0] + app := appIDOf(in.Scope) + if err := c.audit(Audit{Action: "api_key.created", TargetType: "api_key", TargetID: id, ApplicationID: app, After: k}); err != nil { + return err + } + if use == "agent" && !restrict { + if err := c.audit(Audit{Action: "api_key.restrict_destructive_disabled", TargetType: "api_key", TargetID: id, + ApplicationID: app, After: map[string]any{"restrict_destructive": false}}); err != nil { + return err + } + } + k.Secret = secret + c.Versioned(k.version) + return c.Created(k) + }) +} + +func (s *Server) ApiKeysGet(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "apiKeys.get", userOnly: true}, func(c *Call) error { + k, err := c.loadManagedKey(id) + if err != nil { + return err + } + c.Versioned(k.version) + return c.OK(k) + }) +} + +func keyInactive(k apiKey, now time.Time) bool { + return k.RevokedAt != nil || (k.ExpiresAt != nil && !k.ExpiresAt.After(now)) +} + +func (s *Server) ApiKeysUpdate(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "apiKeys.update", userOnly: true}, func(c *Call) error { + k, err := c.loadManagedKey(id) + if err != nil { + return err + } + if keyInactive(k, c.now) { + return httpx.Conflict("api_key_revoked", "This key is revoked or expired.") + } + if err := httpx.CheckIfMatch(c.r, k.version); err != nil { + return err + } + p, err := c.Patch() + if err != nil { + return err + } + if err := p.RejectReadOnly("id", "scope", "mode", "intended_use", "secret_hint", "created_by", "created_at", + "last_used_at", "revoked_at", "expires_at", "secret"); err != nil { + return err + } + var f httpx.Fields + p.RequireNonNull(&f, "name", "permissions", "restrict_destructive") + next := k + if v, present := p.String("name", &f); present && v != nil { + if *v == "" || len([]rune(*v)) > 100 { + f.Max("name", "too_long", 100) + } + next.Name = *v + } + if p.Has("permissions") { + var perms []string + if p.Get("permissions", &perms, &f) { + next.Permissions = dedupe(perms) + } + } + if p.Has("restrict_destructive") { + p.Get("restrict_destructive", &next.RestrictDestructive, &f) + } + if err := f.Err(); err != nil { + return err + } + if err := c.validateKeyPermissions(k.Scope, next.Permissions); err != nil { + return err + } + disabling := k.RestrictDestructive && !next.RestrictDestructive + if err := c.destructiveIf(disabling); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update api_keys set name = :n, permissions = :p::text[], restrict_destructive = :rd + where id = :id`, db.Args{"n": next.Name, "p": db.TextArray(next.Permissions), "rd": next.RestrictDestructive, + "id": k.ID}); err != nil { + return err + } + ks, err := c.queryKeys("k.id = :id", db.Args{"id": k.ID}) + if err != nil { + return err + } + after := ks[0] + app := appIDOf(k.Scope) + if err := c.audit(Audit{Action: "api_key.updated", TargetType: "api_key", TargetID: k.ID, ApplicationID: app, + Before: k, After: after}); err != nil { + return err + } + if disabling && k.IntendedUse == "agent" { + if err := c.audit(Audit{Action: "api_key.restrict_destructive_disabled", TargetType: "api_key", TargetID: k.ID, + ApplicationID: app, Before: map[string]any{"restrict_destructive": true}, + After: map[string]any{"restrict_destructive": false}}); err != nil { + return err + } + } + c.Versioned(after.version) + return c.OK(after) + }) +} + +func (s *Server) ApiKeysRotate(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "apiKeys.rotate", userOnly: true}, func(c *Call) error { + k, err := c.loadManagedKey(id) + if err != nil { + return err + } + if keyInactive(k, c.now) { + return httpx.Conflict("api_key_revoked", "This key is revoked or expired.") + } + secret := newKeySecret(k.Mode) + if _, err := c.q.Exec(c.ctx, `update api_keys set secret_hash = :h, secret_hint = :hint where id = :id`, + db.Args{"h": ids.Hash(secret), "hint": secretHint(secret), "id": k.ID}); err != nil { + return err + } + ks, err := c.queryKeys("k.id = :id", db.Args{"id": k.ID}) + if err != nil { + return err + } + after := ks[0] + if err := c.audit(Audit{Action: "api_key.rotated", TargetType: "api_key", TargetID: k.ID, ApplicationID: appIDOf(k.Scope), + After: map[string]any{"secret_hint": after.SecretHint}}); err != nil { + return err + } + after.Secret = secret + c.Versioned(after.version) + return c.OK(after) + }) +} + +func (s *Server) ApiKeysDelete(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "apiKeys.delete", userOnly: true}, func(c *Call) error { + k, err := c.loadManagedKey(id) + if err != nil { + return err + } + if keyInactive(k, c.now) { + return httpx.Conflict("api_key_revoked", "This key is already revoked or expired.") + } + if err := httpx.CheckIfMatch(c.r, k.version); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update api_keys set revoked_at = now() where id = :id`, db.Args{"id": k.ID}); err != nil { + return err + } + if err := c.audit(Audit{Action: "api_key.revoked", TargetType: "api_key", TargetID: k.ID, ApplicationID: appIDOf(k.Scope), + Before: map[string]any{"revoked_at": nil}, After: map[string]any{"revoked_at": c.now}}); err != nil { + return err + } + return c.NoContent() + }) +} diff --git a/internal/core/applications.go b/internal/core/applications.go new file mode 100644 index 0000000..cd08f6d --- /dev/null +++ b/internal/core/applications.go @@ -0,0 +1,780 @@ +package core + +import ( + "encoding/json" + "net/http" + "net/url" + "regexp" + "slices" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/api/gen" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/schema" +) + +// application is the full API Application object. +type application struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description *string `json:"description"` + IconURL *string `json:"icon_url"` + LaunchURL string `json:"launch_url"` + RedirectURIs []string `json:"redirect_uris"` + SupportURL *string `json:"support_url"` + EmailFromName *string `json:"email_from_name"` + SettingsSchema json.RawMessage `json:"settings_schema"` + SettingsSchemaStats map[string]any `json:"settings_schema_stats"` + Permissions []appPermission `json:"permissions"` + AvailableAppRoles []string `json:"available_app_roles"` + DefaultAppRole *string `json:"default_app_role"` + Visibility string `json:"visibility"` + OwnerUserID *string `json:"owner_user_id"` + OwnerOrganizationID *string `json:"owner_organization_id"` + ReviewStatus string `json:"review_status"` + ReviewNotes *string `json:"review_notes"` + TenantID string `json:"tenant_id"` + TestMode bool `json:"test_mode"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type appSummary struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description *string `json:"description"` + IconURL *string `json:"icon_url"` + Visibility string `json:"visibility"` + ReviewStatus string `json:"review_status"` +} + +func (a appRow) full(stats map[string]int) application { + if stats == nil { + stats = map[string]int{} + } + perms := a.Permissions + if perms == nil { + perms = []appPermission{} + } + return application{ID: a.ID, Slug: a.Slug, Name: a.Name, Description: a.Description, IconURL: a.IconURL, + LaunchURL: a.LaunchURL, RedirectURIs: nonNil(a.RedirectURIs), SupportURL: a.SupportURL, + EmailFromName: a.EmailFromName, SettingsSchema: a.SettingsSchema, + SettingsSchemaStats: map[string]any{"stale_override_counts": stats}, Permissions: perms, + AvailableAppRoles: nonNil(a.AvailableAppRoles), DefaultAppRole: a.DefaultAppRole, Visibility: a.Visibility, + OwnerUserID: a.OwnerUserID, OwnerOrganizationID: a.OwnerOrganizationID, ReviewStatus: a.ReviewStatus, + ReviewNotes: a.ReviewNotes, TenantID: a.TenantID, TestMode: a.TestMode, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt} +} + +func nonNil(s []string) []string { + if s == nil { + return []string{} + } + return s +} + +// staleOverrideCounts counts, per key, users whose override no longer +// validates against the current schema (Settings → stale overrides). +func (c *Call) staleOverrideCounts(a appRow) (map[string]int, error) { + sch, _, _ := schema.Parse(a.SettingsSchema) + rows, err := db.All[map[string]json.RawMessage](c.ctx, c.q, `select overrides from app_settings where application_id = :a`, + db.Args{"a": a.ID}) + if err != nil { + return nil, err + } + out := map[string]int{} + for _, ov := range rows { + for k, v := range ov { + if schema.IsReserved(k) { + if validReservedOverride(k, v) != nil { + out[k]++ + } + continue + } + p, ok := sch.Props[k] + if !ok || len(p.Validate(v, k)) > 0 { + out[k]++ + } + } + } + return out, nil +} + +func (c *Call) loadVisibleApp(id string) (appRow, error) { + a, err := c.loadApp(id) + if err == db.ErrNotFound { + return a, httpx.NotFoundResource("application") + } + if err != nil { + return a, err + } + if ok, err := c.appVisible(a); err != nil { + return a, err + } else if !ok { + return a, httpx.NotFoundResource("application") + } + return a, nil +} + +func (s *Server) ApplicationsList(w http.ResponseWriter, r *http.Request, params gen.ApplicationsListParams) { + s.serve(w, r, opts{op: "applications.list"}, func(c *Call) error { + admin := c.can("applications.manage") + where, args := []string{"true"}, db.Args{} + if params.Visibility != nil { + where = append(where, "a.visibility = :vis") + args["vis"] = string(*params.Visibility) + } + queue := false + if params.ReviewStatus != nil { + where = append(where, "a.review_status = :rs") + args["rs"] = string(*params.ReviewStatus) + queue = admin && *params.ReviewStatus == "pending_review" + } + if params.Owned != nil && *params.Owned { + if !c.p.IsUser() { + return c.OK(httpx.List[appSummary]{Data: []appSummary{}}) + } + where = append(where, `(a.owner_user_id = :me or a.owner_organization_id in (select organization_id + from organization_memberships where user_id = :me and role = 'org_admin' and status = 'active'))`) + args["me"] = c.p.UserID + } + if admin || c.can("tenants.manage") { + if params.TenantId != nil { + where = append(where, "a.tenant_id = :t") + args["t"] = *params.TenantId + } + if params.OwnerUserId != nil { + where = append(where, "a.owner_user_id = :ou") + args["ou"] = *params.OwnerUserId + } + if params.OwnerOrganizationId != nil { + where = append(where, "a.owner_organization_id = :oo") + args["oo"] = *params.OwnerOrganizationId + } + } + lim := httpx.Limit(params.Limit) + filters := httpx.Filters(c.r) + pos, err := c.s.Cursors.Decode(deref(params.Cursor), filters) + if err != nil { + return err + } + // The review queue is oldest first; every other list is newest first. + order, cmp := "a.created_at desc, a.id desc", "<" + if queue { + order, cmp = "a.created_at asc, a.id asc", ">" + } + if pos != nil { + where = append(where, "(a.created_at, a.id) "+cmp+" (:ps::timestamptz, :pi)") + args["ps"], args["pi"] = pos.Sort, pos.ID + } + all, err := db.All[appRow](c.ctx, c.q, `select to_jsonb(a) from applications a where `+ + strings.Join(where, " and ")+" order by "+order, args) + if err != nil { + return err + } + var out []appSummary + var keys []appRow + for _, a := range all { + if ok, err := c.appVisible(a); err != nil { + return err + } else if !ok { + continue + } + out = append(out, appSummary{a.ID, a.Slug, a.Name, a.Description, a.IconURL, a.Visibility, a.ReviewStatus}) + keys = append(keys, a) + if len(out) > lim { + break + } + } + page := httpx.Paginate(c.s.Cursors, out, lim, filters, func(x appSummary) (string, string) { return "", "" }) + if page.Page.HasMore { + last := keys[lim-1] + cur := c.s.Cursors.Encode(httpx.TimeKey(last.CreatedAt), last.ID, filters) + page.Page.NextCursor = &cur + } + return c.OK(page) + }) +} + +func (s *Server) ApplicationsGet(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "applications.get"}, func(c *Call) error { + a, err := c.loadVisibleApp(id) + if err != nil { + return err + } + stats, err := c.staleOverrideCounts(a) + if err != nil { + return err + } + c.Versioned(a.Version) + return c.OK(a.full(stats)) + }) +} + +// ── Validation (Applications → The Application object) ───────────────── + +var ( + slugRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,63}$`) + privateURI = regexp.MustCompile(`^[a-z][a-z0-9+.-]*\.[a-z0-9+.-]+:/`) +) + +func httpsURL(s string) bool { + u, err := url.Parse(s) + return err == nil && u.Scheme == "https" && u.Host != "" && u.User == nil +} + +func validRedirect(s string, testMode bool) bool { + if httpsURL(s) { + return true + } + if privateURI.MatchString(s) { + return true + } + if testMode { + u, err := url.Parse(s) + if err == nil && u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1") { + return true + } + } + return false +} + +// validateAppFields checks every writable field present in p against the +// Application rules, reporting every failure at once. +func validateAppFields(p httpx.Patch, slug string, testMode bool, f *httpx.Fields) (sch *schema.Schema, schemaErr error) { + str := func(field string, min, max int) { + if v, present := p.String(field, f); present && v != nil { + n := len([]rune(*v)) + if n < min { + f.Add(field, "too_short").Min = &min + } else if n > max { + f.Max(field, "too_long", max) + } + } + } + str("name", 1, 100) + str("description", 0, 2000) + str("email_from_name", 1, 50) + str("review_notes", 0, 2000) + for _, field := range []string{"icon_url", "launch_url", "support_url"} { + if v, present := p.String(field, f); present && v != nil && !httpsURL(*v) { + f.Add(field, "invalid_format").Allowed = []string{"https"} + } + } + if p.Has("redirect_uris") { + var uris []string + if p.Get("redirect_uris", &uris, f) { + if len(uris) > 10 { + f.Max("redirect_uris", "too_long", 10) + } + for _, u := range uris { + if !validRedirect(u, testMode) { + f.Add("redirect_uris", "invalid_format").Message = u + break + } + } + } + } + if p.Has("permissions") { + var perms []appPermission + if p.Get("permissions", &perms, f) { + if len(perms) > 100 { + f.Max("permissions", "too_long", 100) + } + keyRe := regexp.MustCompile(`^app\.` + regexp.QuoteMeta(slug) + `\.[a-z0-9_.]{1,64}$`) + seen := map[string]bool{} + for _, pm := range perms { + if !keyRe.MatchString(pm.Key) { + f.Add("permissions", "invalid_format").Pattern = keyRe.String() + break + } + if seen[pm.Key] { + f.Add("permissions", "not_unique").Message = pm.Key + break + } + seen[pm.Key] = true + if pm.Description != nil && len([]rune(*pm.Description)) > 255 { + f.Max("permissions.description", "too_long", 255) + break + } + } + } + } + if p.Has("available_app_roles") { + var roles []string + if p.Get("available_app_roles", &roles, f) { + if len(roles) > 20 { + f.Max("available_app_roles", "too_long", 20) + } + for _, r := range roles { + if !roleNameRe.MatchString(r) { + f.Add("available_app_roles", "invalid_format").Pattern = roleNameRe.String() + break + } + } + } + } + if v, present := p.String("visibility", f); present { + if v == nil || (*v != "public" && *v != "invite_only" && *v != "internal") { + f.Enum("visibility", "public", "invite_only", "internal") + } + } + if v, present := p.String("review_status", f); present { + if v == nil || !slices.Contains([]string{"approved", "pending_review", "rejected", "suspended"}, *v) { + f.Enum("review_status", "approved", "pending_review", "rejected", "suspended") + } + } + if p.Has("settings_schema") { + raw := p["settings_schema"] + if p.IsNull("settings_schema") { + f.Add("settings_schema", "required") + } else { + parsed, issues, reserved := schema.Parse(raw) + if len(reserved) > 0 { + return nil, httpx.E(422, "reserved_settings_key", + "settings_schema may not declare the reserved global keys locale, timezone, theme, or notifications."). + With("keys", reserved) + } + if len(issues) > 0 { + return nil, httpx.E(422, "invalid_settings_schema", "settings_schema breaks a schema rule.").With("fields", issues) + } + sch = parsed + } + } + return sch, nil +} + +// ── POST /v1/applications ────────────────────────────────────────────── + +func (s *Server) ApplicationsCreate(w http.ResponseWriter, r *http.Request, params gen.ApplicationsCreateParams) { + s.serve(w, r, opts{op: "applications.create", idem: idemOptional}, func(c *Call) error { + if err := c.require("applications.manage"); err != nil { + return err + } + p, err := c.Patch() + if err != nil { + return err + } + if err := p.RejectReadOnly("id", "tenant_id", "created_at", "updated_at", "settings_schema_stats", "test_mode"); err != nil { + return err + } + var f httpx.Fields + var slug string + if !p.Get("slug", &slug, &f) || slug == "" { + f.Add("slug", "required") + } else if slug == "platform" { + f.Add("slug", "unknown_value").Message = "anything but platform" + } else if !slugRe.MatchString(slug) { + f.Add("slug", "invalid_format").Pattern = slugRe.String() + } + for _, req := range []string{"name", "launch_url"} { + if v, present := p.String(req, &f); !present || v == nil { + f.Add(req, "required") + } + } + ownerUser, _ := p.String("owner_user_id", &f) + ownerOrg, _ := p.String("owner_organization_id", &f) + if (ownerUser == nil) == (ownerOrg == nil) { + f.Add("owner_user_id", "required").Message = "exactly one of owner_user_id or owner_organization_id" + } + sch, err := validateAppFields(p, slug, c.testMode(), &f) + if err != nil { + return err + } + if err := f.Err(); err != nil { + return err + } + var in struct { + Name string `json:"name"` + Description *string `json:"description"` + IconURL *string `json:"icon_url"` + LaunchURL string `json:"launch_url"` + RedirectURIs []string `json:"redirect_uris"` + SupportURL *string `json:"support_url"` + EmailFromName *string `json:"email_from_name"` + SettingsSchema json.RawMessage `json:"settings_schema"` + Permissions []appPermission `json:"permissions"` + AvailableAppRoles []string `json:"available_app_roles"` + DefaultAppRole *string `json:"default_app_role"` + Visibility *string `json:"visibility"` + ReviewStatus *string `json:"review_status"` + ReviewNotes *string `json:"review_notes"` + } + if err := c.Decode(&in); err != nil { + return err + } + roles := dedupe(in.AvailableAppRoles) + if in.DefaultAppRole != nil && !slices.Contains(roles, *in.DefaultAppRole) { + f.Add("default_app_role", "unknown_value").Allowed = roles + return f.Err() + } + if in.SettingsSchema == nil { + in.SettingsSchema = json.RawMessage(`{"type":"object","properties":{}}`) + } + _ = sch + visibility := "public" + if in.Visibility != nil { + visibility = *in.Visibility + } + review := "approved" + if in.ReviewStatus != nil { + review = *in.ReviewStatus + } + if (review == "rejected" || review == "suspended") && (in.ReviewNotes == nil || *in.ReviewNotes == "") { + return httpx.E(422, "review_notes_required", "review_notes is required for rejected or suspended.") + } + // The owner must exist. + if ownerUser != nil { + if _, err := c.visibleUser(*ownerUser); err != nil { + f.Add("owner_user_id", "unknown_value") + return f.Err() + } + } else if _, err := c.loadOrg(*ownerOrg); err != nil { + f.Add("owner_organization_id", "unknown_value") + return f.Err() + } + t, err := c.ensureTenant(ownerUser, ownerOrg) + if err != nil { + return err + } + if err := tenantWritable(t); err != nil { + return err + } + if t.Restricted { + return subscriptionRequired(t) + } + lim := plans[t.Plan] + if lim.Applications != nil { + var n int + if err := db.One(c.ctx, c.q, &n, `select to_jsonb(count(*)) from applications where tenant_id = :t`, + db.Args{"t": t.ID}); err != nil { + return err + } + if n >= *lim.Applications { + return planLimitErr("applications", *lim.Applications, n, t) + } + } + if lim.AppRoles != nil && len(roles) > *lim.AppRoles { + return planLimitErr("app_roles", *lim.AppRoles, len(roles), t) + } + perms := in.Permissions + if perms == nil { + perms = []appPermission{} + } + id := "app_" + slug + if _, err := c.q.Exec(c.ctx, `insert into applications (id, slug, name, description, icon_url, launch_url, + redirect_uris, support_url, email_from_name, settings_schema, permissions, available_app_roles, + default_app_role, visibility, owner_user_id, owner_organization_id, review_status, review_notes, + tenant_id, test_mode) + values (:id, :slug, :name, :desc, :icon, :launch, :redir::text[], :support, :from, :schema::jsonb, + :perms::jsonb, :roles::text[], :defrole, :vis, :ou, :oo, :rs, :rn, :t, :tm)`, + db.Args{"id": id, "slug": slug, "name": in.Name, "desc": in.Description, "icon": in.IconURL, + "launch": in.LaunchURL, "redir": db.TextArray(in.RedirectURIs), "support": in.SupportURL, + "from": in.EmailFromName, "schema": string(in.SettingsSchema), "perms": db.JSON(perms), + "roles": db.TextArray(roles), "defrole": in.DefaultAppRole, "vis": visibility, "ou": ownerUser, + "oo": ownerOrg, "rs": review, "rn": in.ReviewNotes, "t": t.ID, "tm": c.testMode()}); err != nil { + if _, ok := db.UniqueViolation(err); ok { + return httpx.Conflict("slug_taken", "An Application with this slug already exists.") + } + return err + } + if err := c.seedAppRoles(id, roles); err != nil { + return err + } + a, err := c.loadApp(id) + if err != nil { + return err + } + if err := c.audit(Audit{Action: "application.created", TargetType: "application", TargetID: id, + ApplicationID: &id, TenantID: &t.ID, After: a.full(nil)}); err != nil { + return err + } + c.After(func(ctx contextT) { c.s.rebuildSettingsProjection(ctx, id) }) + c.Versioned(a.Version) + return c.Created(a.full(nil)) + }) +} + +// seedAppRoles creates the empty role__ for each new name. +func (c *Call) seedAppRoles(appID string, names []string) error { + for _, n := range names { + if _, err := c.q.Exec(c.ctx, `insert into roles (id, name, scope, permissions, test_mode) + values (:id, :n, :s, '{}', false) on conflict (id) do nothing`, + db.Args{"id": roleID(appID, n), "n": n, "s": appID}); err != nil { + return err + } + } + return nil +} + +// ── PATCH /v1/applications/{id} ──────────────────────────────────────── + +var ownerFields = []string{"name", "description", "icon_url", "launch_url", "redirect_uris", "support_url", + "email_from_name", "settings_schema", "permissions", "available_app_roles", "default_app_role"} +var moderationFields = []string{"visibility", "review_status", "review_notes", "owner_user_id", "owner_organization_id"} + +func (s *Server) ApplicationsUpdate(w http.ResponseWriter, r *http.Request, id string, params gen.ApplicationsUpdateParams) { + s.serve(w, r, opts{op: "applications.update"}, func(c *Call) error { + a, err := c.loadVisibleApp(id) + if err != nil { + return err + } + admin := c.can("applications.manage") + owner, err := c.isAppOwner(a) + if err != nil { + return err + } + if !admin && !owner { + return httpx.Forbidden("applications.manage") + } + if err := httpx.CheckIfMatch(c.r, a.Version); err != nil { + return err + } + p, err := c.Patch() + if err != nil { + return err + } + if err := p.RejectReadOnly("id", "slug", "tenant_id", "created_at", "updated_at", "settings_schema_stats", "test_mode"); err != nil { + return err + } + if !admin { + for _, mf := range moderationFields { + if p.Has(mf) { + return httpx.E(403, "moderation_field_forbidden", "Only applications.manage can change "+mf+".").With("field", mf) + } + } + } + var f httpx.Fields + p.RequireNonNull(&f, "name", "launch_url", "redirect_uris", "settings_schema", "permissions", + "available_app_roles", "visibility", "review_status") + if _, err := validateAppFields(p, a.Slug, a.TestMode, &f); err != nil { + return err + } + if err := f.Err(); err != nil { + return err + } + next := a + setStr := func(field string, dst **string) { + if v, present := p.String(field, &f); present { + *dst = v + } + } + if v, present := p.String("name", &f); present && v != nil { + next.Name = *v + } + if v, present := p.String("launch_url", &f); present && v != nil { + next.LaunchURL = *v + } + setStr("description", &next.Description) + setStr("icon_url", &next.IconURL) + setStr("support_url", &next.SupportURL) + setStr("email_from_name", &next.EmailFromName) + setStr("default_app_role", &next.DefaultAppRole) + setStr("review_notes", &next.ReviewNotes) + // Decode into fresh slices: next shares a's backing arrays, and + // decoding in place would rewrite a too. + var redirects, roles []string + var perms []appPermission + if p.Get("redirect_uris", &redirects, &f) { + next.RedirectURIs = redirects + } + if p.Get("permissions", &perms, &f) { + next.Permissions = perms + } + if p.Get("available_app_roles", &roles, &f) { + next.AvailableAppRoles = dedupe(roles) + } + if p.Has("settings_schema") { + next.SettingsSchema = p["settings_schema"] + } + if v, present := p.String("visibility", &f); present && v != nil { + next.Visibility = *v + } + ownerChange := p.Has("owner_user_id") || p.Has("owner_organization_id") + if ownerChange { + setStr("owner_user_id", &next.OwnerUserID) + setStr("owner_organization_id", &next.OwnerOrganizationID) + if p.Has("owner_user_id") && next.OwnerUserID != nil && !p.Has("owner_organization_id") { + next.OwnerOrganizationID = nil + } + if p.Has("owner_organization_id") && next.OwnerOrganizationID != nil && !p.Has("owner_user_id") { + next.OwnerUserID = nil + } + if (next.OwnerUserID == nil) == (next.OwnerOrganizationID == nil) { + f.Add("owner_user_id", "required").Message = "exactly one of owner_user_id or owner_organization_id" + } + } + if next.DefaultAppRole != nil && !slices.Contains(next.AvailableAppRoles, *next.DefaultAppRole) { + f.Add("default_app_role", "unknown_value").Allowed = next.AvailableAppRoles + } + if err := f.Err(); err != nil { + return err + } + // review_status transitions. + reviewChanged := false + if v, present := p.String("review_status", &f); present && v != nil && *v != a.ReviewStatus { + allowed := map[string][]string{ + "pending_review": {"approved", "rejected"}, "approved": {"suspended"}, "suspended": {"approved"}, + } + if !slices.Contains(allowed[a.ReviewStatus], *v) { + return httpx.Conflict("invalid_review_transition", "The review_status change from "+a.ReviewStatus+" to "+*v+" isn't allowed.") + } + next.ReviewStatus, reviewChanged = *v, true + } + if (next.ReviewStatus == "rejected" || next.ReviewStatus == "suspended") && reviewChanged && + (next.ReviewNotes == nil || *next.ReviewNotes == "") { + return httpx.E(422, "review_notes_required", "review_notes is required for rejected or suspended.") + } + configEdited := false + for _, of := range ownerFields { + if p.Has(of) { + configEdited = true + } + } + if a.ReviewStatus == "rejected" && configEdited && !reviewChanged { + // The owner's next configuration edit resubmits it. + next.ReviewStatus, reviewChanged = "pending_review", true + } + removedRoles := minus(a.AvailableAppRoles, next.AvailableAppRoles) + removedPerms := minus(permKeys(a.Permissions), permKeys(next.Permissions)) + if err := c.destructiveIf((reviewChanged && (next.ReviewStatus == "rejected" || next.ReviewStatus == "suspended")) || + len(removedRoles) > 0 || len(removedPerms) > 0); err != nil { + return err + } + t, err := c.loadTenant(a.TenantID) + if err != nil { + return err + } + if err := tenantWritable(t); err != nil { + return err + } + addedRoles := minus(next.AvailableAppRoles, a.AvailableAppRoles) + if len(addedRoles) > 0 { + if t.Restricted && !c.testMode() { + return subscriptionRequired(t) + } + if l := plans[t.Plan].AppRoles; l != nil && len(next.AvailableAppRoles) > *l { + return planLimitErr("app_roles", *l, len(next.AvailableAppRoles), t) + } + } + for _, name := range removedRoles { + var n int + if err := db.One(c.ctx, c.q, &n, `select to_jsonb(count(*)) from user_role_assignments where role_id = :r`, + db.Args{"r": roleID(a.ID, name)}); err != nil { + return err + } + if n > 0 { + return httpx.Conflict("app_role_in_use", "A role name being removed still has assignments."). + With("role_id", roleID(a.ID, name)) + } + } + if len(removedPerms) > 0 { + inUse, err := db.All[string](c.ctx, c.q, `select to_jsonb(id) from roles where scope = :a + and permissions && :p::text[] order by id`, db.Args{"a": a.ID, "p": db.TextArray(removedPerms)}) + if err != nil { + return err + } + if len(inUse) > 0 { + return httpx.Conflict("permission_in_use", "A permission being removed is still held by a Role.").With("role_ids", inUse) + } + } + newTenant := a.TenantID + if ownerChange && (deref(next.OwnerUserID) != deref(a.OwnerUserID) || deref(next.OwnerOrganizationID) != deref(a.OwnerOrganizationID)) { + nt, err := c.ensureTenant(next.OwnerUserID, next.OwnerOrganizationID) + if err != nil { + return err + } + if t.Tier != "shared" || nt.Tier != "shared" { + return httpx.Conflict("ownership_change_requires_migration", "Ownership can move only between shared-tier Tenants.") + } + newTenant = nt.ID + } + if next.Permissions == nil { + next.Permissions = []appPermission{} + } + if _, err := c.q.Exec(c.ctx, `update applications set name = :name, description = :desc, icon_url = :icon, + launch_url = :launch, redirect_uris = :redir::text[], support_url = :support, email_from_name = :from, + settings_schema = :schema::jsonb, permissions = :perms::jsonb, available_app_roles = :roles::text[], + default_app_role = :defrole, visibility = :vis, owner_user_id = :ou, owner_organization_id = :oo, + review_status = :rs, review_notes = :rn, tenant_id = :t + where id = :id`, + db.Args{"name": next.Name, "desc": next.Description, "icon": next.IconURL, "launch": next.LaunchURL, + "redir": db.TextArray(next.RedirectURIs), "support": next.SupportURL, "from": next.EmailFromName, + "schema": string(next.SettingsSchema), "perms": db.JSON(next.Permissions), + "roles": db.TextArray(next.AvailableAppRoles), "defrole": next.DefaultAppRole, "vis": next.Visibility, + "ou": next.OwnerUserID, "oo": next.OwnerOrganizationID, "rs": next.ReviewStatus, "rn": next.ReviewNotes, + "t": newTenant, "id": a.ID}); err != nil { + return err + } + if newTenant != a.TenantID { + for _, table := range []string{"entitlements", "app_profiles", "app_settings"} { + if _, err := c.q.Exec(c.ctx, `update `+table+` set tenant_id = :t where application_id = :a`, + db.Args{"t": newTenant, "a": a.ID}); err != nil { + return err + } + } + if _, err := c.q.Exec(c.ctx, `update webhook_subscriptions set tenant_id = :t where scope = :a`, + db.Args{"t": newTenant, "a": a.ID}); err != nil { + return err + } + } + for _, name := range removedRoles { + if _, err := c.q.Exec(c.ctx, `delete from roles where id = :r`, db.Args{"r": roleID(a.ID, name)}); err != nil { + return err + } + } + if err := c.seedAppRoles(a.ID, addedRoles); err != nil { + return err + } + after, err := c.loadApp(a.ID) + if err != nil { + return err + } + if err := c.audit(Audit{Action: "application.updated", TargetType: "application", TargetID: a.ID, + ApplicationID: &a.ID, Before: a.full(nil), After: after.full(nil)}); err != nil { + return err + } + if reviewChanged { + if err := c.audit(Audit{Action: "application.review_status_changed", TargetType: "application", TargetID: a.ID, + ApplicationID: &a.ID, Before: map[string]any{"review_status": a.ReviewStatus}, + After: map[string]any{"review_status": after.ReviewStatus, "review_notes": after.ReviewNotes}}); err != nil { + return err + } + if err := c.emit("application.review_status_changed", &a.ID, map[string]any{"application_id": a.ID, + "review_status": after.ReviewStatus, "review_notes": after.ReviewNotes}); err != nil { + return err + } + } + if p.Has("settings_schema") { + c.After(func(ctx contextT) { c.s.rebuildSettingsProjection(ctx, a.ID) }) + } + stats, err := c.staleOverrideCounts(after) + if err != nil { + return err + } + c.Versioned(after.Version) + return c.OK(after.full(stats)) + }) +} + +func permKeys(ps []appPermission) []string { + out := make([]string, len(ps)) + for i, p := range ps { + out[i] = p.Key + } + return out +} + +// minus is a − b. +func minus(a, b []string) []string { + var out []string + for _, x := range a { + if !slices.Contains(b, x) { + out = append(out, x) + } + } + return out +} diff --git a/internal/core/audit.go b/internal/core/audit.go new file mode 100644 index 0000000..653949e --- /dev/null +++ b/internal/core/audit.go @@ -0,0 +1,179 @@ +package core + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/api/gen" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" +) + +type auditEvent struct { + ID string `json:"id"` + Action string `json:"action"` + Actor actorRef `json:"actor"` + Target actorRef `json:"target"` + TargetUserID *string `json:"target_user_id"` + ApplicationID *string `json:"application_id"` + OrganizationID *string `json:"organization_id"` + TenantID *string `json:"tenant_id"` + Before json.RawMessage `json:"before"` + After json.RawMessage `json:"after"` + RequestID *string `json:"request_id"` + Timestamp time.Time `json:"timestamp"` +} + +const auditSQL = `select jsonb_build_object('id', a.id, 'action', a.action, + 'actor', jsonb_build_object('type', a.actor_type, 'id', a.actor_id), + 'target', jsonb_build_object('type', a.target_type, 'id', a.target_id), + 'target_user_id', a.target_user_id, 'application_id', a.application_id, 'organization_id', a.organization_id, + 'tenant_id', a.tenant_id, 'before', a.before, 'after', a.after, 'request_id', a.request_id, + 'timestamp', a.timestamp) from audit_events a` + +// ownsTenant: the owner_user_id User, or an org_admin of the owning Organization. +func (c *Call) ownsTenant(t tenantRow) (bool, error) { + if c.p == nil || !c.p.IsUser() { + return false, nil + } + if t.OwnerUserID != nil { + return *t.OwnerUserID == c.p.UserID, nil + } + return c.isOrgAdmin(*t.OwnerOrganizationID) +} + +// auditScope returns the forced scope for the caller, or 403. +func (c *Call) auditScope(targetUser, tenant *string) (where []string, args db.Args, err error) { + args = db.Args{} + if c.can("audit.view") { + return nil, args, nil + } + if app, ok := c.p.AppKey(); ok { + if !c.p.Perms["audit.view"] { + return nil, nil, httpx.Forbidden("audit.view") + } + args["forced_app"] = app + return []string{"a.application_id = :forced_app"}, args, nil + } + if c.p.IsUser() && targetUser != nil && *targetUser == c.p.UserID { + args["forced_user"] = c.p.UserID + return []string{"a.target_user_id = :forced_user"}, args, nil + } + if tenant != nil { + t, err := c.loadTenant(*tenant) + if err == nil { + if owner, err := c.ownsTenant(t); err != nil { + return nil, nil, err + } else if owner { + args["forced_tenant"] = t.ID + return []string{"a.tenant_id = :forced_tenant"}, args, nil + } + } + } + return nil, nil, httpx.Forbidden("audit.view") +} + +func (s *Server) AuditList(w http.ResponseWriter, r *http.Request, params gen.AuditListParams) { + s.serve(w, r, opts{op: "audit.list"}, func(c *Call) error { + var targetUser *string + if params.TargetUserId != nil { + uid, err := c.selfID(*params.TargetUserId) + if err != nil { + return err + } + targetUser = &uid + } + where, args, err := c.auditScope(targetUser, params.TenantId) + if err != nil { + return err + } + add := func(cond, key string, v *string) { + if v != nil { + where = append(where, cond) + args[key] = *v + } + } + add("a.target_user_id = :tu", "tu", targetUser) + add("a.target_type = :tt", "tt", enumStr(params.TargetType)) + add("a.target_id = :ti", "ti", params.TargetId) + add("a.actor_id = :ai", "ai", params.ActorId) + add("a.actor_type = :at", "at", enumStr(params.ActorType)) + add("a.application_id = :app", "app", params.ApplicationId) + add("a.organization_id = :org", "org", params.OrganizationId) + add("a.tenant_id = :tnt", "tnt", params.TenantId) + if params.Action != nil && len(*params.Action) > 0 { + acts := make([]string, len(*params.Action)) + for i, a := range *params.Action { + acts[i] = string(a) + } + where = append(where, "a.action = any(:acts::text[])") + args["acts"] = db.TextArray(acts) + } + if params.Since != nil { + where = append(where, "a.timestamp >= :since::timestamptz") + args["since"] = db.Time(*params.Since) + } + if params.Until != nil { + where = append(where, "a.timestamp < :until::timestamptz") + args["until"] = db.Time(*params.Until) + } + lim := httpx.Limit(params.Limit) + args["lim"] = int64(lim + 1) + filters := httpx.Filters(c.r) + pos, err := c.s.Cursors.Decode(deref(params.Cursor), filters) + if err != nil { + return err + } + if pos != nil { + where = append(where, "(a.timestamp, a.id) < (:ps::timestamptz, :pi)") + args["ps"], args["pi"] = pos.Sort, pos.ID + } + if len(where) == 0 { + where = []string{"true"} + } + rows, err := db.All[auditEvent](c.ctx, c.q, auditSQL+" where "+strings.Join(where, " and ")+ + " order by a.timestamp desc, a.id desc limit :lim", args) + if err != nil { + return err + } + if params.TenantId != nil { + if t, err := c.loadTenant(*params.TenantId); err == nil { + if days := plans[t.Plan].AuditHotDays; days != nil { + c.headers.Set("Audit-Hot-Window-Start", c.now.AddDate(0, 0, -*days).Format(time.RFC3339)) + } + } + } + return c.OK(httpx.Paginate(c.s.Cursors, rows, lim, filters, func(x auditEvent) (string, string) { + return httpx.TimeKey(x.Timestamp), x.ID + })) + }) +} + +func (s *Server) AuditGet(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "audit.get"}, func(c *Call) error { + var ev auditEvent + if err := db.One(c.ctx, c.q, &ev, auditSQL+" where a.id = :id", db.Args{"id": id}); err == db.ErrNotFound { + return httpx.NotFoundResource("audit_event") + } else if err != nil { + return err + } + where, args, err := c.auditScope(ev.TargetUserID, ev.TenantID) + if err != nil { + return httpx.NotFoundResource("audit_event") + } + if len(where) > 0 { + args["id"] = id + rows, err := c.q.Query(c.ctx, `select to_jsonb(a.id) from audit_events a where a.id = :id and `+ + strings.Join(where, " and "), args) + if err != nil { + return err + } + if len(rows) == 0 { + return httpx.NotFoundResource("audit_event") + } + } + return c.OK(ev) + }) +} diff --git a/internal/core/auth_flows.go b/internal/core/auth_flows.go new file mode 100644 index 0000000..e976f5a --- /dev/null +++ b/internal/core/auth_flows.go @@ -0,0 +1,968 @@ +package core + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/access" + "github.com/Adron/substratalapps.com/internal/api/gen" + "github.com/Adron/substratalapps.com/internal/auth" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/ids" +) + +// ── App tokens (Auth → Getting an app token) ─────────────────────────── + +type appTokenClaims struct { + Iss string `json:"iss"` + Aud string `json:"aud"` + Sub string `json:"sub"` + Sid string `json:"sid"` + OrgID *string `json:"org_id"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + EntitlementStatus string `json:"entitlement_status"` + EffectivePermissions []string `json:"effective_permissions"` + TestMode bool `json:"test_mode"` + Iat int64 `json:"iat"` + Exp int64 `json:"exp"` + Jti string `json:"jti"` +} + +// appVisibleTo applies Applications → Who can see an Application. +func (c *Call) appVisible(a appRow) (bool, error) { + if c.can("applications.manage") { + return true, nil + } + if app, ok := c.p.AppKey(); ok && app == a.ID { + return true, nil + } + if owner, err := c.isAppOwner(a); err != nil || owner { + return owner, err + } + if a.ReviewStatus != "approved" { + return false, nil + } + switch a.Visibility { + case "public": + return true, nil + case "invite_only": + if c.p.IsUser() { + return c.userHasAccessPath(c.p.UserID, a.ID) + } + } + return false, nil +} + +// mintAppToken checks access right now and signs the per-Application JWT. +// The same checks back app-tokens, authorization-codes, and oauth/token. +func (c *Call) mintAppToken(u userRow, sessionID string, a appRow, orgID *string) (string, error) { + if u.Status == "suspended" { + return "", httpx.E(403, "account_suspended", "This account is suspended.") + } + if a.ReviewStatus != "approved" { + return "", httpx.Conflict("application_not_available", "This Application isn't available."). + With("review_status", a.ReviewStatus) + } + t, err := c.loadTenant(a.TenantID) + if err != nil { + return "", err + } + if t.Status == "suspended" { + return "", httpx.E(403, "tenant_suspended", "This Application's Tenant is suspended.").With("tenant_id", t.ID) + } + res, err := c.resolveOne(u.ID, a.ID) + if err != nil { + return "", err + } + entitlementRequired := func(status string) error { + return httpx.E(403, "entitlement_required", "This user has no active entitlement to "+a.ID+"."). + With("application_id", a.ID).With("entitlement_status", status) + } + if !res.Allowed { + return "", entitlementRequired(res.Res.Status) + } + var attributed *string + if orgID != nil { + via, ok := access.ActiveViaOrg(u.ID, *orgID, res.Paths, c.now) + if !ok { + return "", entitlementRequired(res.Res.Status) + } + attributed = via.OrganizationID + } else if res.Res.ActiveVia != nil && res.Res.ActiveVia.IsOrg() { + attributed = res.Res.ActiveVia.OrganizationID + } + roles, err := c.appRolesFor(u.ID, a.ID) + if err != nil { + return "", err + } + _, perms := access.EffectivePermissions(true, roles) + return auth.SignJWT(c.ctx, c.s.Signer, appTokenClaims{ + Iss: c.s.Issuer, Aud: a.ID, Sub: u.ID, Sid: sessionID, OrgID: attributed, Email: u.Email, + EmailVerified: u.EmailVerified, EntitlementStatus: "active", EffectivePermissions: perms, + TestMode: u.TestMode, Iat: c.now.Unix(), Exp: c.now.Add(appTokenTTL).Unix(), Jti: ids.New(ids.AppTokenJTI), + }) +} + +func (s *Server) AuthCreateAppToken(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "auth.createAppToken", userOnly: true}, func(c *Call) error { + var in struct { + ApplicationID string `json:"application_id"` + OrganizationID *string `json:"organization_id"` + } + if err := c.Decode(&in); err != nil { + return err + } + if in.ApplicationID == "" { + return httpx.Invalid("application_id is required.") + } + a, err := c.loadApp(in.ApplicationID) + if err == db.ErrNotFound { + return httpx.NotFoundResource("application") + } + if err != nil { + return err + } + if ok, err := c.appVisibleForToken(a); err != nil { + return err + } else if !ok { + return httpx.NotFoundResource("application") + } + u, err := c.findUser("u.id = :id", db.Args{"id": c.p.UserID}) + if err != nil { + return err + } + tok, err := c.mintAppToken(u, c.p.SessionID, a, in.OrganizationID) + if err != nil { + return err + } + return c.OK(map[string]any{"token_type": "Bearer", "app_token": tok, + "expires_in": int(appTokenTTL.Seconds()), "application_id": a.ID}) + }) +} + +// appVisibleForToken: a User may request a token for any Application they +// can see, or have any access path to (an internal app granted by the +// platform is invisible in the catalog but still launchable by its users). +func (c *Call) appVisibleForToken(a appRow) (bool, error) { + if ok, err := c.appVisible(a); err != nil || ok { + return ok, err + } + return c.userHasAccessPath(c.p.UserID, a.ID) +} + +// ── Hosted flow: authorization codes + PKCE ──────────────────────────── + +func (s *Server) AuthCreateAuthorizationCode(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "auth.createAuthorizationCode", userOnly: true}, func(c *Call) error { + var in struct { + ApplicationID string `json:"application_id"` + RedirectURI string `json:"redirect_uri"` + CodeChallenge string `json:"code_challenge"` + CodeChallengeMethod string `json:"code_challenge_method"` + State string `json:"state"` + } + if err := c.Decode(&in); err != nil { + return err + } + var f httpx.Fields + if in.ApplicationID == "" { + f.Add("application_id", "required") + } + if in.RedirectURI == "" { + f.Add("redirect_uri", "required") + } + if len(in.CodeChallenge) < 43 || len(in.CodeChallenge) > 128 { + f.Add("code_challenge", "invalid_format") + } + if in.CodeChallengeMethod != "S256" { + f.Enum("code_challenge_method", "S256") + } + if err := f.Err(); err != nil { + return err + } + a, err := c.loadApp(in.ApplicationID) + if err == db.ErrNotFound { + return httpx.NotFoundResource("application") + } + if err != nil { + return err + } + if ok, err := c.appVisibleForToken(a); err != nil { + return err + } else if !ok { + return httpx.NotFoundResource("application") + } + registered := false + for _, u := range a.RedirectURIs { + if u == in.RedirectURI { + registered = true + } + } + if !registered { + return httpx.E(422, "redirect_uri_not_registered", "redirect_uri must exactly match one of the Application's redirect_uris.") + } + u, err := c.findUser("u.id = :id", db.Args{"id": c.p.UserID}) + if err != nil { + return err + } + // Access is checked here too, so a User without access is never + // redirected into the app holding a code. + if _, err := c.mintAppToken(u, c.p.SessionID, a, nil); err != nil { + return err + } + code := ids.Secret(ids.AuthorizationCode) + if _, err := c.q.Exec(c.ctx, `insert into auth_tokens (token_hash, kind, user_id, payload, expires_at) + values (:h, 'authorization_code', :u, :p::jsonb, :exp::timestamptz)`, + db.Args{"h": ids.Hash(code), "u": u.ID, "exp": db.Time(c.now.Add(60 * time.Second)), + "p": db.JSON(map[string]any{"application_id": a.ID, "redirect_uri": in.RedirectURI, + "code_challenge": in.CodeChallenge, "session_id": c.p.SessionID})}); err != nil { + return err + } + q := url.Values{"code": {code}} + if in.State != "" { + q.Set("state", in.State) + } + sep := "?" + if strings.Contains(in.RedirectURI, "?") { + sep = "&" + } + return c.Created(map[string]any{"code": code, "expires_in": 60, "redirect_to": in.RedirectURI + sep + q.Encode()}) + }) +} + +// oauthErr responds with the OAuth 2.0 error shape this one endpoint uses. +func (c *Call) oauthErr(status int, code, desc string) error { + c.status = status + c.body = map[string]string{"error": code, "error_description": desc} + return nil +} + +func (s *Server) AuthExchangeOAuthToken(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "auth.exchangeOAuthToken", public: true}, func(c *Call) error { + var in struct { + GrantType string `json:"grant_type"` + ClientID string `json:"client_id"` + Code string `json:"code"` + CodeVerifier string `json:"code_verifier"` + RedirectURI string `json:"redirect_uri"` + RefreshToken string `json:"refresh_token"` + } + if strings.HasPrefix(c.r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { + v, err := url.ParseQuery(string(c.raw)) + if err != nil { + return c.oauthErr(400, "invalid_request", "The form body couldn't be parsed.") + } + in.GrantType, in.ClientID, in.Code = v.Get("grant_type"), v.Get("client_id"), v.Get("code") + in.CodeVerifier, in.RedirectURI, in.RefreshToken = v.Get("code_verifier"), v.Get("redirect_uri"), v.Get("refresh_token") + } else if err := json.Unmarshal(nonEmpty(c.raw), &in); err != nil { + return c.oauthErr(400, "invalid_request", "The body must be JSON or form-encoded.") + } + if in.GrantType != "authorization_code" && in.GrantType != "refresh_token" { + return c.oauthErr(400, "unsupported_grant_type", "grant_type must be authorization_code or refresh_token.") + } + a, err := c.loadApp(in.ClientID) + if err == db.ErrNotFound || in.ClientID == "" { + return c.oauthErr(401, "invalid_client", "Unknown client_id.") + } + if err != nil { + return err + } + var u userRow + var sessionID string + if in.GrantType == "authorization_code" { + t, owner, valid, err := c.lookupToken(in.Code, "authorization_code") + if err != nil { + return err + } + if !valid { + return c.oauthErr(400, "invalid_grant", "The authorization code is invalid, expired, or already used.") + } + // Single use, even when the exchange below fails. + if err := c.consumeToken(t.TokenHash); err != nil { + return err + } + challenge, _ := t.Payload["code_challenge"].(string) + sum := sha256.Sum256([]byte(in.CodeVerifier)) + computed := base64.RawURLEncoding.EncodeToString(sum[:]) + if t.Payload["application_id"] != a.ID || t.Payload["redirect_uri"] != in.RedirectURI || + subtle.ConstantTimeCompare([]byte(computed), []byte(challenge)) != 1 { + return c.oauthErr(400, "invalid_grant", "The code, redirect_uri, or code_verifier doesn't match.") + } + sessionID, _ = t.Payload["session_id"].(string) + var sessRevoked *time.Time + if err := db.One(c.ctx, c.q, &sessRevoked, `select to_jsonb(revoked_at) from sessions where id = :s`, + db.Args{"s": sessionID}); err != nil || sessRevoked != nil { + return c.oauthErr(400, "invalid_grant", "The session that issued this code has ended.") + } + u = owner + } else { + rt, owner, err := c.lookupRefresh(in.RefreshToken) + if err == db.ErrNotFound || (err == nil && (rt.ApplicationID == nil || *rt.ApplicationID != a.ID)) { + return c.oauthErr(400, "invalid_grant", "The refresh token is invalid.") + } + if err != nil { + return err + } + if rt.RotatedAt != nil || rt.RevokedAt != nil || !rt.IdleExpires.After(c.now) { + if _, err := c.rotateRefresh(rt); err != nil { + c.CommitAnyway() + } + return c.oauthErr(400, "invalid_grant", "The refresh token was already used or its session ended.") + } + sessionID, u = rt.SessionID, owner + // Re-check access every time before rotating. + tok, err := c.mintAppToken(u, sessionID, a, nil) + if err != nil { + return c.oauthAccessDenied(err) + } + next, err := c.rotateRefresh(rt) + if err != nil { + return err + } + return c.OK(oauthTokenResponse(tok, next, a.ID)) + } + tok, err := c.mintAppToken(u, sessionID, a, nil) + if err != nil { + return c.oauthAccessDenied(err) + } + rtk := ids.Secret(ids.RefreshToken) + if _, err := c.q.Exec(c.ctx, `insert into refresh_tokens (token_hash, session_id, application_id) values (:h, :s, :a)`, + db.Args{"h": ids.Hash(rtk), "s": sessionID, "a": a.ID}); err != nil { + return err + } + return c.OK(oauthTokenResponse(tok, rtk, a.ID)) + }) +} + +func oauthTokenResponse(tok, refresh, appID string) map[string]any { + return map[string]any{"token_type": "Bearer", "app_token": tok, "expires_in": int(appTokenTTL.Seconds()), + "refresh_token": refresh, "refresh_token_expires_in": int(sessionIdleTTL.Seconds()), "application_id": appID} +} + +// oauthAccessDenied maps an access failure to OAuth's access_denied, with +// the API's own error code carried in error_description. +func (c *Call) oauthAccessDenied(err error) error { + if e, ok := err.(*httpx.Error); ok { + c.CommitAnyway() + desc := e.Code + if st, ok := e.Details["entitlement_status"]; ok { + desc = fmt.Sprintf("%s (entitlement_status: %v)", e.Code, st) + } + _ = c.oauthErr(400, "access_denied", desc) + return nil + } + return err +} + +func nonEmpty(b []byte) []byte { + if len(strings.TrimSpace(string(b))) == 0 { + return []byte("{}") + } + return b +} + +// ── Email verification ───────────────────────────────────────────────── + +func tokenInvalid() error { + return httpx.E(400, "token_invalid", "This token is expired, already used, or unknown.") +} + +func (s *Server) AuthVerifyEmail(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "auth.verifyEmail", public: true}, func(c *Call) error { + var in struct { + Token string `json:"token"` + } + if err := c.Decode(&in); err != nil { + return err + } + t, u, valid, err := c.lookupToken(in.Token, "email_verify") + if err != nil { + return err + } + if !valid { + return tokenInvalid() + } + if err := c.consumeToken(t.TokenHash); err != nil { + return err + } + if newEmail, ok := t.Payload["new_email"].(string); ok && newEmail != "" { + if u.PendingEmail == nil || !strings.EqualFold(*u.PendingEmail, newEmail) { + return tokenInvalid() + } + if _, taken, err := c.emailTaken(newEmail); err != nil { + return err + } else if taken { + return httpx.Conflict("email_taken", "Another account now uses this email.") + } + if _, err := c.q.Exec(c.ctx, `update users set email = pending_email, pending_email = null, + email_verified = true where id = :u`, db.Args{"u": u.ID}); err != nil { + return err + } + after, _ := c.findUser("u.id = :id", db.Args{"id": u.ID}) + if err := c.audit(Audit{Action: "user.email_changed", TargetType: "user", TargetID: u.ID, + TargetUserID: &u.ID, Before: map[string]any{"email": u.Email}, After: map[string]any{"email": after.Email}, + ActorType: "user", ActorID: u.ID}); err != nil { + return err + } + return c.NoContent() + } + if _, err := c.q.Exec(c.ctx, `update users set email_verified = true where id = :u`, db.Args{"u": u.ID}); err != nil { + return err + } + return c.NoContent() + }) +} + +func (s *Server) AuthResendEmailVerification(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "auth.resendEmailVerification", public: true}, func(c *Call) error { + var in struct { + Email string `json:"email"` + TestMode bool `json:"test_mode"` + } + if err := c.Decode(&in); err != nil { + return err + } + if err := c.setMode(in.TestMode); err != nil { + return err + } + // Always 202: whether the email exists or is verified is never revealed. + u, err := c.findUser("u.email = :e and u.deleted_at is null", db.Args{"e": strings.TrimSpace(in.Email)}) + if err == nil && u.Status == "active" && c.emailQuota(u.Email, "resend") { + if !u.EmailVerified { + if err := c.sendVerification(u.ID, u.Email, u.SignupApplicationID, nil); err != nil { + return err + } + } else if u.PendingEmail != nil { + if err := c.sendVerification(u.ID, *u.PendingEmail, nil, map[string]any{"new_email": *u.PendingEmail}); err != nil { + return err + } + } + } else if err != nil && err != db.ErrNotFound { + return err + } + return c.Accepted(nil) + }) +} + +// ── Password lifecycle ───────────────────────────────────────────────── + +func (s *Server) AuthForgotPassword(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "auth.forgotPassword", public: true}, func(c *Call) error { + var in struct { + Email string `json:"email"` + TestMode bool `json:"test_mode"` + } + if err := c.Decode(&in); err != nil { + return err + } + if err := c.setMode(in.TestMode); err != nil { + return err + } + u, err := c.findUser("u.email = :e and u.deleted_at is null", db.Args{"e": strings.TrimSpace(in.Email)}) + if err == db.ErrNotFound || (err == nil && u.Status != "active") { + return c.Accepted(nil) + } + if err != nil { + return err + } + if !c.emailQuota(u.Email, "forgot") { + return c.Accepted(nil) + } + var methods []string + methods, err = db.All[string](c.ctx, c.q, `select to_jsonb(method) from user_identities where user_id = :u`, db.Args{"u": u.ID}) + if err != nil { + return err + } + hasPassword, hasSSO := false, false + for _, m := range methods { + hasPassword = hasPassword || m == "password" + hasSSO = hasSSO || m == "sso" + } + if hasSSO && !hasPassword { + c.notice(u.Email, "sso_signin", "Signing in to Substratal", + "Your account signs in through your organization's identity provider, so there's no password to reset.") + return c.Accepted(nil) + } + // An active User created without a password (POST /v1/users with + // status active) sets their first one this way too. + tok, err := c.issueToken("password_reset", ids.PasswordReset, u.ID, time.Hour, nil) + if err != nil { + return err + } + c.notice(u.Email, "password_reset", "Reset your Substratal password", + fmt.Sprintf("Set a new password:\n\n%s\n\nThis link expires in 1 hour. If you didn't ask for this, ignore this email.", + c.link("/reset-password", tok))) + return c.Accepted(nil) + }) +} + +// setPassword writes a new password, creating the password identity if +// the User has none yet. +func (c *Call) setPassword(userID, hash string) (string, error) { + ident, err := c.passwordIdentity(userID) + if err == db.ErrNotFound { + id := ids.New(ids.UserIdentity) + _, err := c.q.Exec(c.ctx, `insert into user_identities (id, user_id, method, password_hash) + values (:id, :u, 'password', :h)`, db.Args{"id": id, "u": userID, "h": hash}) + return id, err + } + if err != nil { + return "", err + } + _, err = c.q.Exec(c.ctx, `update user_identities set password_hash = :h, failed_login_count = 0, + failed_login_window_start = null, locked_until = null where id = :i`, db.Args{"h": hash, "i": ident.ID}) + return ident.ID, err +} + +func (s *Server) AuthResetPassword(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "auth.resetPassword", public: true}, func(c *Call) error { + var in struct { + Token string `json:"token"` + NewPassword string `json:"new_password"` + } + if err := c.Decode(&in); err != nil { + return err + } + t, u, valid, err := c.lookupToken(in.Token, "password_reset") + if err != nil { + return err + } + if !valid || u.Status != "active" { + return tokenInvalid() + } + if err := auth.CheckPolicy(in.NewPassword, u.Email); err != nil { + return weakPassword(err) + } + hash, err := auth.HashPassword(in.NewPassword) + if err != nil { + return err + } + if err := c.consumeToken(t.TokenHash); err != nil { + return err + } + if _, err := c.setPassword(u.ID, hash); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update users set email_verified = true where id = :u`, db.Args{"u": u.ID}); err != nil { + return err + } + if err := c.revokeSessions(u.ID, "password_reset", ""); err != nil { + return err + } + if err := c.audit(Audit{Action: "user.password_reset", TargetType: "user", TargetID: u.ID, + TargetUserID: &u.ID, ActorType: "user", ActorID: u.ID}); err != nil { + return err + } + c.notice(u.Email, "password_changed", "Your Substratal password was changed", + "Your password was just reset and every session was signed out. If this wasn't you, contact support.") + return c.NoContent() + }) +} + +func (s *Server) AuthChangePassword(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe) { + s.serve(w, r, opts{op: "auth.changePassword", userOnly: true}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + if !c.isSelf(uid) { + return httpx.Forbidden("") + } + var in struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + } + if err := c.Decode(&in); err != nil { + return err + } + u, err := c.findUser("u.id = :id", db.Args{"id": uid}) + if err != nil { + return err + } + ident, err := c.passwordIdentity(uid) + if err == db.ErrNotFound || (err == nil && (ident.PasswordHash == nil || !auth.VerifyPassword(in.CurrentPassword, *ident.PasswordHash))) { + return invalidCredentials() + } + if err != nil { + return err + } + if err := auth.CheckPolicy(in.NewPassword, u.Email); err != nil { + return weakPassword(err) + } + hash, err := auth.HashPassword(in.NewPassword) + if err != nil { + return err + } + if _, err := c.setPassword(uid, hash); err != nil { + return err + } + if err := c.revokeSessions(uid, "password_changed", c.p.SessionID); err != nil { + return err + } + if err := c.audit(Audit{Action: "user.password_changed", TargetType: "user", TargetID: uid, TargetUserID: &uid}); err != nil { + return err + } + c.notice(u.Email, "password_changed", "Your Substratal password was changed", + "Your password was just changed and your other sessions were signed out. If this wasn't you, reset it now.") + return c.NoContent() + }) +} + +// ── Invitations ──────────────────────────────────────────────────────── + +func (s *Server) AuthAcceptInvitation(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "auth.acceptInvitation", public: true}, func(c *Call) error { + var in struct { + Token string `json:"token"` + Password string `json:"password"` + DisplayName *string `json:"display_name"` + } + if err := c.Decode(&in); err != nil { + return err + } + t, u, valid, err := c.lookupToken(in.Token, "invitation") + if err != nil { + return err + } + if !valid || u.Status != "invited" { + return tokenInvalid() + } + if err := auth.CheckPolicy(in.Password, u.Email); err != nil { + return weakPassword(err) + } + hash, err := auth.HashPassword(in.Password) + if err != nil { + return err + } + if err := c.consumeToken(t.TokenHash); err != nil { + return err + } + identityID, err := c.setPassword(u.ID, hash) + if err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update users set status = 'active', email_verified = true where id = :u`, db.Args{"u": u.ID}); err != nil { + return err + } + if in.DisplayName != nil && *in.DisplayName != "" { + if _, err := c.q.Exec(c.ctx, `update profiles set display_name = :d where user_id = :u`, + db.Args{"d": truncate(*in.DisplayName, 100), "u": u.ID}); err != nil { + return err + } + } + // Accepting the invitation accepts every pending membership too. + orgs, err := db.All[string](c.ctx, c.q, `select to_jsonb(organization_id) from organization_memberships + where user_id = :u and status = 'pending'`, db.Args{"u": u.ID}) + if err != nil { + return err + } + pairs, err := c.userAppPairs(u.ID) + if err != nil { + return err + } + watch, err := c.watchAccess(pairs) + if err != nil { + return err + } + for _, org := range orgs { + if err := c.activateMembership(u.ID, org, u.ID); err != nil { + return err + } + } + if err := c.flip(watch, "org_member_added", "org_member_removed"); err != nil { + return err + } + u.Status, u.EmailVerified = "active", true + sess, err := c.issueSession(u, []string{"pwd"}, identityID) + if err != nil { + return err + } + return c.OK(sess) + }) +} + +// ── MFA (TOTP) ───────────────────────────────────────────────────────── + +func (c *Call) selfOnly(id gen.UserIdOrMe) (string, error) { + uid, err := c.selfID(id) + if err != nil { + return "", err + } + if !c.isSelf(uid) { + return "", httpx.Forbidden("") + } + return uid, nil +} + +func (c *Call) mfaIdentity(uid string) (identityRow, error) { + ident, err := c.passwordIdentity(uid) + if err == db.ErrNotFound { + return ident, httpx.Conflict("mfa_not_applicable", "MFA applies only to password sign-in; this User has none.") + } + return ident, err +} + +func (s *Server) AuthEnrollMfaTotp(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe) { + s.serve(w, r, opts{op: "auth.enrollMfaTotp", userOnly: true}, func(c *Call) error { + uid, err := c.selfOnly(id) + if err != nil { + return err + } + ident, err := c.mfaIdentity(uid) + if err != nil { + return err + } + if ident.MFAEnabled { + return httpx.Conflict("mfa_already_enabled", "TOTP is already enabled.") + } + u, err := c.findUser("u.id = :id", db.Args{"id": uid}) + if err != nil { + return err + } + secret := auth.NewTOTPSecret() + enc, err := c.s.Encrypter.Encrypt(c.ctx, secret) + if err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update user_identities set mfa_pending_secret = :s, + mfa_pending_expires_at = :exp::timestamptz where id = :i`, + db.Args{"s": enc, "exp": db.Time(c.now.Add(10 * time.Minute)), "i": ident.ID}); err != nil { + return err + } + return c.OK(map[string]any{"secret": secret, "otpauth_uri": auth.OTPAuthURI(secret, u.Email), "expires_in": 600}) + }) +} + +func (s *Server) AuthConfirmMfaTotp(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe) { + s.serve(w, r, opts{op: "auth.confirmMfaTotp", userOnly: true}, func(c *Call) error { + uid, err := c.selfOnly(id) + if err != nil { + return err + } + var in struct { + Code string `json:"code"` + } + if err := c.Decode(&in); err != nil { + return err + } + ident, err := c.mfaIdentity(uid) + if err != nil { + return err + } + if ident.MFAEnabled { + return httpx.Conflict("mfa_already_enabled", "TOTP is already enabled.") + } + if ident.MFAPendingSecret == nil || ident.MFAPendingExp == nil || !ident.MFAPendingExp.After(c.now) { + return httpx.Conflict("mfa_enrollment_expired", "Start enrollment again; the pending secret expired.") + } + secret, err := c.s.Encrypter.Decrypt(c.ctx, *ident.MFAPendingSecret) + if err != nil { + return err + } + step, ok := auth.VerifyTOTP(secret, in.Code, c.now, 0) + if !ok { + return httpx.E(401, "invalid_mfa_code", "The code is wrong or was already used.") + } + if _, err := c.q.Exec(c.ctx, `update user_identities set mfa_enabled = true, mfa_secret = mfa_pending_secret, + mfa_pending_secret = null, mfa_pending_expires_at = null, mfa_last_step = :s where id = :i`, + db.Args{"s": step, "i": ident.ID}); err != nil { + return err + } + codes := auth.NewRecoveryCodes(10) + for _, code := range codes { + h, err := auth.HashPassword(code) + if err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `insert into mfa_recovery_codes (user_identity_id, code_hash) values (:i, :h)`, + db.Args{"i": ident.ID, "h": h}); err != nil { + return err + } + } + if err := c.audit(Audit{Action: "user.mfa_enabled", TargetType: "user", TargetID: uid, TargetUserID: &uid}); err != nil { + return err + } + if u, err := c.findUser("u.id = :id", db.Args{"id": uid}); err == nil { + c.notice(u.Email, "mfa_enabled", "Two-step verification is on", "TOTP two-step verification was just enabled on your Substratal account.") + } + return c.OK(map[string]any{"mfa_enabled": true, "recovery_codes": codes}) + }) +} + +func (s *Server) AuthDisableMfaTotp(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe) { + s.serve(w, r, opts{op: "auth.disableMfaTotp", userOnly: true}, func(c *Call) error { + uid, err := c.selfOnly(id) + if err != nil { + return err + } + var in struct { + Code string `json:"code"` + RecoveryCode string `json:"recovery_code"` + } + if err := c.Decode(&in); err != nil { + return err + } + if (in.Code == "") == (in.RecoveryCode == "") { + return httpx.Invalid("Send exactly one of code or recovery_code.") + } + ident, err := c.mfaIdentity(uid) + if err != nil { + return err + } + if !ident.MFAEnabled { + return httpx.Conflict("mfa_not_enabled", "TOTP isn't enabled.") + } + bucket := limit{5, 15 * time.Minute} + if n, err := c.peek("mfa-disable:"+uid, bucket); err != nil { + return err + } else if n >= 5 { + return httpx.E(429, "too_many_attempts", "Too many wrong codes; try again later.").RetryAfter(900) + } + _, ok, err := c.verifySecondFactor(ident, in.Code, in.RecoveryCode) + if err != nil { + return err + } + if !ok { + _ = c.hit("mfa-disable:"+uid, bucket, false) + c.CommitAnyway() + return httpx.E(401, "invalid_mfa_code", "The code is wrong or was already used.") + } + if err := c.clearMFA(ident.ID); err != nil { + return err + } + if err := c.audit(Audit{Action: "user.mfa_disabled", TargetType: "user", TargetID: uid, TargetUserID: &uid}); err != nil { + return err + } + if u, err := c.findUser("u.id = :id", db.Args{"id": uid}); err == nil { + c.notice(u.Email, "mfa_disabled", "Two-step verification is off", "TOTP two-step verification was just turned off on your Substratal account.") + } + return c.NoContent() + }) +} + +func (c *Call) clearMFA(identityID string) error { + if _, err := c.q.Exec(c.ctx, `update user_identities set mfa_enabled = false, mfa_secret = null, + mfa_pending_secret = null, mfa_pending_expires_at = null, mfa_last_step = null where id = :i`, + db.Args{"i": identityID}); err != nil { + return err + } + _, err := c.q.Exec(c.ctx, `delete from mfa_recovery_codes where user_identity_id = :i`, db.Args{"i": identityID}) + return err +} + +func (s *Server) AuthResetMfaTotp(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe) { + s.serve(w, r, opts{op: "auth.resetMfaTotp"}, func(c *Call) error { + if err := c.require("users.manage"); err != nil { + return err + } + uid, err := c.selfID(id) + if err != nil { + return err + } + if _, err := c.visibleUser(uid); err != nil { + return err + } + ident, err := c.mfaIdentity(uid) + if err != nil { + return err + } + if !ident.MFAEnabled { + return httpx.Conflict("mfa_not_enabled", "TOTP isn't enabled.") + } + if err := c.clearMFA(ident.ID); err != nil { + return err + } + if err := c.revokeSessions(uid, "admin", ""); err != nil { + return err + } + if err := c.audit(Audit{Action: "user.mfa_reset", TargetType: "user", TargetID: uid, TargetUserID: &uid}); err != nil { + return err + } + return c.NoContent() + }) +} + +// ── Sessions ─────────────────────────────────────────────────────────── + +func (s *Server) AuthListSessions(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe, params gen.AuthListSessionsParams) { + s.serve(w, r, opts{op: "auth.listSessions"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + if !c.isSelf(uid) && !c.can("users.manage") { + return httpx.Forbidden("users.manage") + } + if _, err := c.visibleUser(uid); err != nil { + return err + } + lim := httpx.Limit(params.Limit) + filters := httpx.Filters(c.r) + pos, err := c.s.Cursors.Decode(deref(params.Cursor), filters) + if err != nil { + return err + } + type sess struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + LastSeenAt time.Time `json:"last_seen_at"` + ExpiresAt time.Time `json:"expires_at"` + IPAddress *string `json:"ip_address"` + UserAgent *string `json:"user_agent"` + AMR []string `json:"amr"` + Current bool `json:"current"` + } + args := db.Args{"u": uid, "lim": int64(lim + 1), "cur": deref(&c.p.SessionID)} + where := "" + if pos != nil { + where = ` and (s.created_at, s.id) < (:ps::timestamptz, :pi)` + args["ps"], args["pi"] = pos.Sort, pos.ID + } + rows, err := db.All[sess](c.ctx, c.q, `select jsonb_build_object('id', s.id, 'created_at', s.created_at, + 'last_seen_at', s.last_seen_at, 'expires_at', least(s.idle_expires_at, s.absolute_expires_at), + 'ip_address', host(s.ip_address), 'user_agent', s.user_agent, 'amr', s.amr, 'current', s.id = :cur) + from sessions s where s.user_id = :u and s.revoked_at is null + and least(s.idle_expires_at, s.absolute_expires_at) > now()`+where+` + order by s.created_at desc, s.id desc limit :lim`, args) + if err != nil { + return err + } + return c.OK(httpx.Paginate(c.s.Cursors, rows, lim, filters, func(x sess) (string, string) { + return httpx.TimeKey(x.CreatedAt), x.ID + })) + }) +} + +func (s *Server) AuthRevokeSession(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe, sessionID string) { + s.serve(w, r, opts{op: "auth.revokeSession"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + reason := "logout" + if !c.isSelf(uid) { + if !c.can("users.manage") { + return httpx.Forbidden("users.manage") + } + reason = "admin" + } + n, err := c.q.Exec(c.ctx, `update sessions set revoked_at = now(), revoked_reason = :r + where id = :s and user_id = :u and revoked_at is null`, db.Args{"r": reason, "s": sessionID, "u": uid}) + if err != nil { + return err + } + if n == 0 { + return httpx.NotFoundResource("session") + } + return c.NoContent() + }) +} diff --git a/internal/core/auth_session.go b/internal/core/auth_session.go new file mode 100644 index 0000000..e78eb50 --- /dev/null +++ b/internal/core/auth_session.go @@ -0,0 +1,604 @@ +package core + +import ( + "net/http" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/auth" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/ids" +) + +// Token lifetimes (Auth → Token types at a glance). +const ( + accessTTL = 15 * time.Minute + appTokenTTL = 5 * time.Minute + sessionIdleTTL = 30 * 24 * time.Hour + sessionAbsTTL = 90 * 24 * time.Hour + mfaChallengeTTL = 5 * time.Minute + lockoutWindow = 15 * time.Minute + lockoutAfter = 5 +) + +type authSession struct { + TokenType string `json:"token_type"` + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + RefreshTokenExpiresIn int `json:"refresh_token_expires_in"` + SessionID string `json:"session_id"` + UserID string `json:"user_id"` + EmailVerified bool `json:"email_verified"` +} + +// setAnyMode lets a public endpoint look a token up across modes; it must +// pin the owner's mode (setMode) before doing anything else. +func (c *Call) setAnyMode() error { + _, err := c.q.Query(c.ctx, `select to_jsonb(set_config('app.current_test_mode', 'any', true))`, nil) + return err +} + +// signAccess mints a platform access token for a session. +func (c *Call) signAccess(userID, sessionID string, amr []string, emailVerified, testMode bool) (string, error) { + return auth.SignJWT(c.ctx, c.s.Signer, accessClaims{ + Iss: c.s.Issuer, Aud: platformAudience, Sub: userID, Sid: sessionID, AMR: amr, + EmailVerified: emailVerified, TestMode: testMode, + Iat: c.now.Unix(), Exp: c.now.Add(accessTTL).Unix(), Jti: ids.New(ids.AccessTokenJTI), + }) +} + +// issueSession creates a session with a refresh token and an access token, +// and records the login on the User and the identity used. +func (c *Call) issueSession(u userRow, amr []string, identityID string) (authSession, error) { + sid := ids.New(ids.Session) + var ipArg any + if c.ip != "" { + ipArg = c.ip + } + ua := c.r.UserAgent() + if len(ua) > 512 { + ua = ua[:512] + } + if _, err := c.q.Exec(c.ctx, `insert into sessions (id, user_id, amr, ip_address, user_agent, test_mode, + idle_expires_at, absolute_expires_at) + values (:id, :u, :amr::text[], (case when :ip::text ~ '^[0-9a-fA-F:.]+$' then :ip::inet end), :ua, :tm, + :idle::timestamptz, :abs::timestamptz)`, + db.Args{"id": sid, "u": u.ID, "amr": db.TextArray(amr), "ip": ipArg, "ua": ua, "tm": u.TestMode, + "idle": db.Time(c.now.Add(sessionIdleTTL)), "abs": db.Time(c.now.Add(sessionAbsTTL))}); err != nil { + return authSession{}, err + } + rtk := ids.Secret(ids.RefreshToken) + if _, err := c.q.Exec(c.ctx, `insert into refresh_tokens (token_hash, session_id) values (:h, :s)`, + db.Args{"h": ids.Hash(rtk), "s": sid}); err != nil { + return authSession{}, err + } + if _, err := c.q.Exec(c.ctx, `update users set last_login_at = now() where id = :u`, db.Args{"u": u.ID}); err != nil { + return authSession{}, err + } + if identityID != "" { + if _, err := c.q.Exec(c.ctx, `update user_identities set last_used_at = now(), failed_login_count = 0, + failed_login_window_start = null, locked_until = null where id = :i`, db.Args{"i": identityID}); err != nil { + return authSession{}, err + } + } + at, err := c.signAccess(u.ID, sid, amr, u.EmailVerified, u.TestMode) + if err != nil { + return authSession{}, err + } + return authSession{TokenType: "Bearer", AccessToken: at, ExpiresIn: int(accessTTL.Seconds()), + RefreshToken: rtk, RefreshTokenExpiresIn: int(sessionIdleTTL.Seconds()), + SessionID: sid, UserID: u.ID, EmailVerified: u.EmailVerified}, nil +} + +// revokeSessions revokes a User's sessions (all, or all but `except`). +func (c *Call) revokeSessions(userID, reason, except string) error { + _, err := c.q.Exec(c.ctx, `update sessions set revoked_at = now(), revoked_reason = :r + where user_id = :u and revoked_at is null and id <> :except`, + db.Args{"u": userID, "r": reason, "except": except}) + return err +} + +func weakPassword(err error) error { + reason, _ := auth.WeakReason(err) + return httpx.E(422, "weak_password", "The password doesn't meet the password policy.").With("reason", reason) +} + +// ── POST /v1/auth/signup ─────────────────────────────────────────────── + +func (s *Server) AuthSignup(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "auth.signup", public: true, idem: idemOptional}, func(c *Call) error { + var in struct { + Email string `json:"email"` + Password string `json:"password"` + DisplayName *string `json:"display_name"` + ApplicationID *string `json:"application_id"` + TestMode bool `json:"test_mode"` + } + if err := c.Decode(&in); err != nil { + return err + } + if err := c.setMode(in.TestMode); err != nil { + return err + } + var f httpx.Fields + addr, ok := validEmail(in.Email) + if in.Email == "" { + f.Add("email", "required") + } else if !ok { + f.Add("email", "invalid_format") + } + if in.Password == "" { + f.Add("password", "required") + } + if in.DisplayName != nil && (len([]rune(*in.DisplayName)) < 1 || len([]rune(*in.DisplayName)) > 100) { + f.Max("display_name", "too_long", 100) + } + if in.ApplicationID != nil { + if _, err := c.loadApp(*in.ApplicationID); err == db.ErrNotFound { + f.Add("application_id", "unknown_value") + } else if err != nil { + return err + } + } + if err := f.Err(); err != nil { + return err + } + if err := auth.CheckPolicy(in.Password, addr); err != nil { + return weakPassword(err) + } + if existing, taken, err := c.emailTaken(addr); err != nil { + return err + } else if taken { + if existing.Status == "invited" { + // Re-send the invitation; counts against the resend limit. + if c.emailQuota(addr, "resend") { + if err := c.sendInvitation(existing.ID, existing.Email, existing.SignupApplicationID); err != nil { + return err + } + } + c.CommitAnyway() + return httpx.Conflict("email_taken", "An invitation is pending for this email; check your inbox."). + With("reason", "invitation_pending") + } + return httpx.Conflict("email_taken", "A user with this email already exists.") + } + hash, err := auth.HashPassword(in.Password) + if err != nil { + return err + } + u, err := c.createUser(newUser{Email: addr, Status: "active", DisplayName: deref(in.DisplayName), + SignupApp: in.ApplicationID, PasswordHash: hash}) + if err != nil { + return err + } + if err := c.sendVerification(u.ID, u.Email, in.ApplicationID, nil); err != nil { + return err + } + var identityID string + _ = db.One(c.ctx, c.q, &identityID, `select to_jsonb(id) from user_identities where user_id = :u and method = 'password'`, db.Args{"u": u.ID}) + sess, err := c.issueSession(u, []string{"pwd"}, identityID) + if err != nil { + return err + } + return c.Created(sess) + }) +} + +// emailQuota enforces "3 per email per hour" for resend/forgot/invitation +// re-sends. It reports whether another email may be sent; over the limit +// the endpoint still answers normally, so it never reveals anything. +func (c *Call) emailQuota(addr, kind string) bool { + err := c.hit("email:"+strings.ToLower(addr)+":"+kind+":"+boolStr(c.testMode()), perHour(3), false) + return err == nil +} + +func boolStr(b bool) string { + if b { + return "test" + } + return "live" +} + +// ── POST /v1/auth/login ──────────────────────────────────────────────── + +type identityRow struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Method string `json:"method"` + PasswordHash *string `json:"password_hash"` + MFAEnabled bool `json:"mfa_enabled"` + MFASecret *string `json:"mfa_secret"` + MFAPendingSecret *string `json:"mfa_pending_secret"` + MFAPendingExp *time.Time `json:"mfa_pending_expires_at"` + MFALastStep *int64 `json:"mfa_last_step"` + FailedCount int `json:"failed_login_count"` + FailedWindow *time.Time `json:"failed_login_window_start"` + LockedUntil *time.Time `json:"locked_until"` + LastUsedAt *time.Time `json:"last_used_at"` +} + +func (c *Call) passwordIdentity(userID string) (identityRow, error) { + var i identityRow + err := db.One(c.ctx, c.q, &i, `select to_jsonb(i) from user_identities i + where user_id = :u and method = 'password' order by created_at limit 1`, db.Args{"u": userID}) + return i, err +} + +func invalidCredentials() error { + return httpx.E(401, "invalid_credentials", "The email or password is incorrect.") +} + +func (s *Server) AuthLogin(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "auth.login", public: true}, func(c *Call) error { + var in struct { + Email string `json:"email"` + Password string `json:"password"` + TestMode bool `json:"test_mode"` + } + if err := c.Decode(&in); err != nil { + return err + } + if in.Email == "" || in.Password == "" { + return httpx.Invalid("email and password are required.") + } + if err := c.setMode(in.TestMode); err != nil { + return err + } + u, err := c.findUser("u.email = :e and u.deleted_at is null", db.Args{"e": strings.TrimSpace(in.Email)}) + if err == db.ErrNotFound { + auth.BurnPasswordCheck(in.Password) + return invalidCredentials() + } + if err != nil { + return err + } + ident, err := c.passwordIdentity(u.ID) + if err == db.ErrNotFound || (err == nil && ident.PasswordHash == nil) { + auth.BurnPasswordCheck(in.Password) + return invalidCredentials() + } + if err != nil { + return err + } + if ident.LockedUntil != nil && ident.LockedUntil.After(c.now) { + return httpx.E(429, "too_many_attempts", "Too many failed attempts; try again later."). + RetryAfter(int(ident.LockedUntil.Sub(c.now).Seconds()) + 1) + } + if !auth.VerifyPassword(in.Password, *ident.PasswordHash) { + c.CommitAnyway() + return c.recordLoginFailure(ident) + } + if u.Status == "suspended" { + return httpx.E(403, "account_suspended", "This account is suspended.") + } + if u.Status != "active" { + return invalidCredentials() + } + if ident.MFAEnabled { + tok, err := c.issueToken("mfa_challenge", ids.MFAChallenge, u.ID, mfaChallengeTTL, + map[string]any{"identity_id": ident.ID}) + if err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update user_identities set failed_login_count = 0, + failed_login_window_start = null where id = :i`, db.Args{"i": ident.ID}); err != nil { + return err + } + s.Log.Info("security", "event", "mfa_challenge", "user", u.ID, "request_id", c.requestID, "ip", c.ip) + return c.OK(map[string]any{"mfa_required": true, "mfa_token": tok, + "methods": []string{"totp", "recovery_code"}, "expires_in": int(mfaChallengeTTL.Seconds())}) + } + sess, err := c.issueSession(u, []string{"pwd"}, ident.ID) + if err != nil { + return err + } + s.Log.Info("security", "event", "login", "user", u.ID, "request_id", c.requestID, "ip", c.ip) + return c.OK(sess) + }) +} + +// recordLoginFailure counts a failure in a rolling 15-minute window and +// locks the identity for 15 minutes at 5 failures. +func (c *Call) recordLoginFailure(ident identityRow) error { + count, window := ident.FailedCount+1, c.now + if ident.FailedWindow != nil && c.now.Sub(*ident.FailedWindow) < lockoutWindow { + window = *ident.FailedWindow + } else { + count = 1 + } + var locked any + if count >= lockoutAfter { + locked = db.Time(c.now.Add(lockoutWindow)) + } + if _, err := c.q.Exec(c.ctx, `update user_identities set failed_login_count = :n, + failed_login_window_start = :w::timestamptz, locked_until = :l::timestamptz where id = :i`, + db.Args{"n": count, "w": db.Time(window), "l": locked, "i": ident.ID}); err != nil { + return err + } + if locked != nil { + c.s.Log.Warn("security", "event", "lockout", "user", ident.UserID, "request_id", c.requestID, "ip", c.ip) + } + return invalidCredentials() +} + +// ── POST /v1/auth/mfa/verify ─────────────────────────────────────────── + +type authTokenRow struct { + TokenHash string `json:"token_hash"` + Kind string `json:"kind"` + UserID string `json:"user_id"` + Payload map[string]any `json:"payload"` + Attempts int `json:"attempts"` + ExpiresAt time.Time `json:"expires_at"` + ConsumedAt *time.Time `json:"consumed_at"` +} + +// lookupToken finds an unexpired, unconsumed token of kind in any mode, +// then pins the transaction to its User's mode and returns that User. +func (c *Call) lookupToken(tok, kind string) (authTokenRow, userRow, bool, error) { + if err := c.setAnyMode(); err != nil { + return authTokenRow{}, userRow{}, false, err + } + var t authTokenRow + err := db.One(c.ctx, c.q, &t, `select to_jsonb(t) from auth_tokens t where token_hash = :h and kind = :k`, + db.Args{"h": ids.Hash(tok), "k": kind}) + if err == db.ErrNotFound { + return t, userRow{}, false, c.setMode(false) + } + if err != nil { + return t, userRow{}, false, err + } + u, err := c.findUser("u.id = :id", db.Args{"id": t.UserID}) + if err != nil { + return t, u, false, err + } + if err := c.setMode(u.TestMode); err != nil { + return t, u, false, err + } + valid := t.ConsumedAt == nil && t.ExpiresAt.After(c.now) + return t, u, valid, nil +} + +func (c *Call) consumeToken(hash string) error { + _, err := c.q.Exec(c.ctx, `update auth_tokens set consumed_at = now() where token_hash = :h`, db.Args{"h": hash}) + return err +} + +// verifySecondFactor checks a TOTP code (refusing reuse within its window) +// or consumes a recovery code. It returns the amr entry used. +func (c *Call) verifySecondFactor(ident identityRow, code, recovery string) (string, bool, error) { + if code != "" && ident.MFASecret != nil { + secret, err := c.s.Encrypter.Decrypt(c.ctx, *ident.MFASecret) + if err != nil { + return "", false, err + } + last := int64(0) + if ident.MFALastStep != nil { + last = *ident.MFALastStep + } + step, ok := auth.VerifyTOTP(secret, code, c.now, last) + if !ok { + return "", false, nil + } + _, err = c.q.Exec(c.ctx, `update user_identities set mfa_last_step = :s where id = :i`, + db.Args{"s": step, "i": ident.ID}) + return "otp", true, err + } + if recovery != "" { + type rc struct { + Hash string `json:"code_hash"` + } + codes, err := db.All[rc](c.ctx, c.q, `select to_jsonb(r) from mfa_recovery_codes r + where user_identity_id = :i and used_at is null`, db.Args{"i": ident.ID}) + if err != nil { + return "", false, err + } + norm := auth.NormalizeRecoveryCode(recovery) + for _, code := range codes { + if auth.VerifyPassword(norm, code.Hash) { + _, err := c.q.Exec(c.ctx, `update mfa_recovery_codes set used_at = now() + where user_identity_id = :i and code_hash = :h`, db.Args{"i": ident.ID, "h": code.Hash}) + return "recovery_code", true, err + } + } + } + return "", false, nil +} + +func (s *Server) AuthVerifyMfa(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "auth.verifyMfa", public: true}, func(c *Call) error { + var in struct { + MFAToken string `json:"mfa_token"` + Code string `json:"code"` + RecoveryCode string `json:"recovery_code"` + } + if err := c.Decode(&in); err != nil { + return err + } + if in.MFAToken == "" || (in.Code == "") == (in.RecoveryCode == "") { + return httpx.Invalid("mfa_token and exactly one of code or recovery_code are required.") + } + tokenInvalid := httpx.E(401, "mfa_token_invalid", "The MFA challenge expired, was used, or is exhausted. Start over at login.") + t, u, valid, err := c.lookupToken(in.MFAToken, "mfa_challenge") + if err != nil { + return err + } + if !valid || t.Attempts >= 5 { + return tokenInvalid + } + identityID, _ := t.Payload["identity_id"].(string) + var ident identityRow + if err := db.One(c.ctx, c.q, &ident, `select to_jsonb(i) from user_identities i where id = :i`, + db.Args{"i": identityID}); err != nil { + return tokenInvalid + } + method, ok, err := c.verifySecondFactor(ident, in.Code, in.RecoveryCode) + if err != nil { + return err + } + if !ok { + c.CommitAnyway() + if _, err := c.q.Exec(c.ctx, `update auth_tokens set attempts = attempts + 1 where token_hash = :h`, + db.Args{"h": t.TokenHash}); err != nil { + return err + } + return httpx.E(401, "invalid_mfa_code", "The code is wrong or was already used.") + } + if u.Status != "active" { + if u.Status == "suspended" { + return httpx.E(403, "account_suspended", "This account is suspended.") + } + return tokenInvalid + } + if err := c.consumeToken(t.TokenHash); err != nil { + return err + } + sess, err := c.issueSession(u, []string{"pwd", method}, ident.ID) + if err != nil { + return err + } + return c.OK(sess) + }) +} + +// ── POST /v1/auth/token/refresh ──────────────────────────────────────── + +type refreshRow struct { + TokenHash string `json:"token_hash"` + SessionID string `json:"session_id"` + ApplicationID *string `json:"application_id"` + RotatedAt *time.Time `json:"rotated_at"` + UserID string `json:"user_id"` + AMR []string `json:"amr"` + RevokedAt *time.Time `json:"revoked_at"` + IdleExpires time.Time `json:"idle_expires_at"` + AbsExpires time.Time `json:"absolute_expires_at"` +} + +// lookupRefresh resolves a refresh token in any mode and pins the owner's. +func (c *Call) lookupRefresh(tok string) (refreshRow, userRow, error) { + if err := c.setAnyMode(); err != nil { + return refreshRow{}, userRow{}, err + } + var rt refreshRow + err := db.One(c.ctx, c.q, &rt, `select to_jsonb(r) || jsonb_build_object('user_id', s.user_id, 'amr', s.amr, + 'revoked_at', s.revoked_at, 'idle_expires_at', s.idle_expires_at, 'absolute_expires_at', s.absolute_expires_at) + from refresh_tokens r join sessions s on s.id = r.session_id where r.token_hash = :h`, + db.Args{"h": ids.Hash(tok)}) + if err != nil { + _ = c.setMode(false) + return rt, userRow{}, err + } + u, err := c.findUser("u.id = :id", db.Args{"id": rt.UserID}) + if err != nil { + return rt, u, err + } + return rt, u, c.setMode(u.TestMode) +} + +// rotateRefresh enforces single use: a token presented after rotation is +// theft, and revokes the whole session (Auth → token/refresh). +func (c *Call) rotateRefresh(rt refreshRow) (string, error) { + if rt.RotatedAt != nil { + c.CommitAnyway() + if _, err := c.q.Exec(c.ctx, `update sessions set revoked_at = now(), revoked_reason = 'refresh_token_reused' + where id = :s and revoked_at is null`, db.Args{"s": rt.SessionID}); err != nil { + return "", err + } + c.s.Log.Warn("security", "event", "refresh_token_reused", "user", rt.UserID, "session", rt.SessionID, + "request_id", c.requestID, "ip", c.ip) + return "", httpx.E(401, "refresh_token_reused", "This refresh token was already used; the session has been revoked.") + } + if rt.RevokedAt != nil || !rt.IdleExpires.After(c.now) || !rt.AbsExpires.After(c.now) { + return "", httpx.ErrSessionRevoked() + } + if _, err := c.q.Exec(c.ctx, `update refresh_tokens set rotated_at = now() where token_hash = :h`, + db.Args{"h": rt.TokenHash}); err != nil { + return "", err + } + next := ids.Secret(ids.RefreshToken) + if _, err := c.q.Exec(c.ctx, `insert into refresh_tokens (token_hash, session_id, application_id) values (:h, :s, :a)`, + db.Args{"h": ids.Hash(next), "s": rt.SessionID, "a": rt.ApplicationID}); err != nil { + return "", err + } + if _, err := c.q.Exec(c.ctx, `update sessions set last_seen_at = now(), + idle_expires_at = least(absolute_expires_at, :idle::timestamptz) where id = :s`, + db.Args{"s": rt.SessionID, "idle": db.Time(c.now.Add(sessionIdleTTL))}); err != nil { + return "", err + } + return next, nil +} + +func (s *Server) AuthRefreshToken(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "auth.refreshToken", public: true}, func(c *Call) error { + var in struct { + RefreshToken string `json:"refresh_token"` + } + if err := c.Decode(&in); err != nil { + return err + } + if !strings.HasPrefix(in.RefreshToken, ids.RefreshToken) { + return httpx.ErrUnauthenticated() + } + rt, u, err := c.lookupRefresh(in.RefreshToken) + if err == db.ErrNotFound || (err == nil && rt.ApplicationID != nil) { + return httpx.ErrUnauthenticated() + } + if err != nil { + return err + } + if err := c.hit("refresh:"+rt.SessionID, perMinute(30), true); err != nil { + return err + } + if u.Status == "suspended" || u.Status == "deleted" { + return httpx.ErrSessionRevoked() + } + next, err := c.rotateRefresh(rt) + if err != nil { + return err + } + at, err := c.signAccess(u.ID, rt.SessionID, rt.AMR, u.EmailVerified, u.TestMode) + if err != nil { + return err + } + return c.OK(authSession{TokenType: "Bearer", AccessToken: at, ExpiresIn: int(accessTTL.Seconds()), + RefreshToken: next, RefreshTokenExpiresIn: int(sessionIdleTTL.Seconds()), + SessionID: rt.SessionID, UserID: u.ID, EmailVerified: u.EmailVerified}) + }) +} + +// ── POST /v1/auth/logout ─────────────────────────────────────────────── + +func (s *Server) AuthLogout(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "auth.logout", userOnly: true}, func(c *Call) error { + var in struct { + AllSessions bool `json:"all_sessions"` + } + if err := c.Decode(&in); err != nil { + return err + } + if err := c.destructiveIf(in.AllSessions); err != nil { + return err + } + var err error + if in.AllSessions { + err = c.revokeSessions(c.p.UserID, "logout", "") + } else { + _, err = c.q.Exec(c.ctx, `update sessions set revoked_at = now(), revoked_reason = 'logout' + where id = :s and revoked_at is null`, db.Args{"s": c.p.SessionID}) + } + if err != nil { + return err + } + return c.NoContent() + }) +} + +// AuthSsoCallback is reserved until the SSO broker integration ships. +func (s *Server) AuthSsoCallback(w http.ResponseWriter, r *http.Request, provider string) { + s.serve(w, r, opts{op: "auth.ssoCallback", public: true}, func(c *Call) error { + return httpx.E(501, "not_implemented", "SSO sign-in isn't available yet.") + }) +} diff --git a/internal/core/auth_test.go b/internal/core/auth_test.go new file mode 100644 index 0000000..e8ee5df --- /dev/null +++ b/internal/core/auth_test.go @@ -0,0 +1,306 @@ +package core_test + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "net/url" + "strings" + "testing" + "time" + + "crypto/rsa" + + "github.com/Adron/substratalapps.com/internal/auth" + "github.com/Adron/substratalapps.com/internal/testenv" +) + +func TestSignupLoginRefreshLogout(t *testing.T) { + e := testenv.New(t) + u := e.Signup() + + me := e.Get("/v1/users/me", u.Token).Expect(200) + if me.Str("id") != u.ID || me.JSON()["email_verified"] != false { + t.Fatalf("me = %s", me.Body) + } + if _, ok := e.Mail.Last(u.Email, "email_verify"); !ok { + t.Fatal("signup should send a verification email") + } + + // Wrong password: one code for every failure, no enumeration. + e.Post("/v1/auth/login", "", map[string]any{"email": u.Email, "password": "not the right password"}). + ExpectErr(401, "invalid_credentials") + e.Post("/v1/auth/login", "", map[string]any{"email": "nobody+" + testenv.Unique() + "@example.com", "password": "whatever passphrase"}). + ExpectErr(401, "invalid_credentials") + + // Refresh rotates; presenting the old token again revokes the session. + r1 := e.Post("/v1/auth/token/refresh", "", map[string]any{"refresh_token": u.Refresh}).Expect(200) + e.Get("/v1/users/me", r1.Token()).Expect(200) + e.Post("/v1/auth/token/refresh", "", map[string]any{"refresh_token": u.Refresh}).ExpectErr(401, "refresh_token_reused") + e.Get("/v1/users/me", r1.Token()).ExpectErr(401, "session_revoked") + e.Post("/v1/auth/token/refresh", "", map[string]any{"refresh_token": r1.Str("refresh_token")}).ExpectErr(401, "session_revoked") + + // Logout is immediate. + s := e.Login(u.Email, u.Password) + e.Post("/v1/auth/logout", s.Token(), map[string]any{}).Expect(204) + e.Get("/v1/users/me", s.Token()).ExpectErr(401, "session_revoked") +} + +func TestLockout(t *testing.T) { + e := testenv.New(t) + u := e.Signup() + for i := 0; i < 5; i++ { + e.Post("/v1/auth/login", "", map[string]any{"email": u.Email, "password": "wrong passphrase " + testenv.Unique()}). + ExpectErr(401, "invalid_credentials") + } + r := e.Post("/v1/auth/login", "", map[string]any{"email": u.Email, "password": u.Password}).ExpectErr(429, "too_many_attempts") + if r.Header.Get("Retry-After") == "" { + t.Fatal("lockout should set Retry-After") + } +} + +func TestPasswordPolicy(t *testing.T) { + e := testenv.New(t) + for pw, reason := range map[string]string{"short": "too_short", "passwordpassword": "breached"} { + r := e.Post("/v1/auth/signup", "", map[string]any{"email": "p+" + testenv.Unique() + "@example.com", "password": pw}). + ExpectErr(422, "weak_password") + if r.JSON()["error"].(map[string]any)["details"].(map[string]any)["reason"] != reason { + t.Fatalf("%s: %s", pw, r.Body) + } + } + addr := "same+" + testenv.Unique() + "@example.com" + e.Post("/v1/auth/signup", "", map[string]any{"email": addr, "password": addr}).ExpectErr(422, "weak_password") + u := e.Signup() + e.Post("/v1/auth/signup", "", map[string]any{"email": u.Email, "password": "another long passphrase"}).ExpectErr(409, "email_taken") +} + +func TestMFA(t *testing.T) { + e := testenv.New(t) + u := e.Signup() + enroll := e.Post("/v1/users/me/mfa/totp", u.Token, map[string]any{}).Expect(200) + secret := enroll.Str("secret") + if !strings.HasPrefix(enroll.Str("otpauth_uri"), "otpauth://totp/") { + t.Fatalf("uri = %s", enroll.Str("otpauth_uri")) + } + e.Post("/v1/users/me/mfa/totp/confirm", u.Token, map[string]any{"code": "000000"}).ExpectErr(401, "invalid_mfa_code") + conf := e.Post("/v1/users/me/mfa/totp/confirm", u.Token, map[string]any{"code": auth.TOTPCode(secret, time.Now())}).Expect(200) + codes := toStrings(conf.JSON()["recovery_codes"]) + if len(codes) != 10 { + t.Fatalf("recovery codes = %d", len(codes)) + } + if e.Get("/v1/users/me", u.Token).JSON()["mfa_enabled"] != true { + t.Fatal("mfa_enabled should be true") + } + + // Login now returns a challenge, not a session. + ch := e.Post("/v1/auth/login", "", map[string]any{"email": u.Email, "password": u.Password}).Expect(200) + if ch.JSON()["mfa_required"] != true || ch.Token() != "" { + t.Fatalf("challenge = %s", ch.Body) + } + // The code used at confirm can't be reused in its window. + e.Post("/v1/auth/mfa/verify", "", map[string]any{"mfa_token": ch.Str("mfa_token"), + "code": auth.TOTPCode(secret, time.Now())}).ExpectErr(401, "invalid_mfa_code") + sess := e.Post("/v1/auth/mfa/verify", "", map[string]any{"mfa_token": ch.Str("mfa_token"), "recovery_code": codes[0]}).Expect(200) + e.Get("/v1/users/me", sess.Token()).Expect(200) + // The challenge is single-use. + e.Post("/v1/auth/mfa/verify", "", map[string]any{"mfa_token": ch.Str("mfa_token"), "recovery_code": codes[1]}). + ExpectErr(401, "mfa_token_invalid") + // A recovery code is single-use too. + ch2 := e.Post("/v1/auth/login", "", map[string]any{"email": u.Email, "password": u.Password}).Expect(200) + e.Post("/v1/auth/mfa/verify", "", map[string]any{"mfa_token": ch2.Str("mfa_token"), "recovery_code": codes[0]}). + ExpectErr(401, "invalid_mfa_code") + + // Disable with a recovery code. + e.Post("/v1/users/me/mfa/totp/disable", sess.Token(), map[string]any{"recovery_code": codes[2]}).Expect(204) + e.Login(u.Email, u.Password) // a plain session again +} + +func TestPasswordResetAndEmailVerification(t *testing.T) { + e := testenv.New(t) + u := e.Signup() + e.Post("/v1/auth/email/verify", "", map[string]any{"token": e.Token(u.Email, "email_verify")}).Expect(204) + if e.Get("/v1/users/me", u.Token).JSON()["email_verified"] != true { + t.Fatal("email should be verified") + } + e.Post("/v1/auth/password/forgot", "", map[string]any{"email": u.Email}).Expect(202) + e.Post("/v1/auth/password/forgot", "", map[string]any{"email": "ghost+" + testenv.Unique() + "@example.com"}).Expect(202) + tok := e.Token(u.Email, "password_reset") + e.Post("/v1/auth/password/reset", "", map[string]any{"token": tok, "new_password": "a brand new passphrase"}).Expect(204) + e.Post("/v1/auth/password/reset", "", map[string]any{"token": tok, "new_password": "a brand new passphrase"}). + ExpectErr(400, "token_invalid") + e.Get("/v1/users/me", u.Token).ExpectErr(401, "session_revoked") // reset revokes every session + e.Login(u.Email, "a brand new passphrase") +} + +func TestInvitationAccept(t *testing.T) { + e := testenv.New(t) + addr := "invitee+" + testenv.Unique() + "@example.com" + uid := e.Post("/v1/users", e.Admin, map[string]any{"email": addr}).Expect(201).Str("id") + // Signing up to an invited email is refused and re-sends the invitation. + e.Post("/v1/auth/signup", "", map[string]any{"email": addr, "password": "a long enough passphrase"}). + ExpectErr(409, "email_taken") + sess := e.Post("/v1/auth/invitations/accept", "", map[string]any{"token": e.Token(addr, "invitation"), + "password": "a long enough passphrase", "display_name": "Invited Person"}).Expect(200) + if sess.Str("user_id") != uid { + t.Fatalf("accepted as %s, want %s", sess.Str("user_id"), uid) + } + me := e.Get("/v1/users/me", sess.Token()).Expect(200) + if me.Str("status") != "active" || me.JSON()["email_verified"] != true { + t.Fatalf("me = %s", me.Body) + } + if e.Get("/v1/users/me/profile", sess.Token()).Str("display_name") != "Invited Person" { + t.Fatal("display_name not applied") + } +} + +func jwksKey(t *testing.T, e *testenv.Env, kid string) *rsa.PublicKey { + var set struct { + Keys []auth.JWK `json:"keys"` + } + e.Get("/.well-known/jwks.json", "").Expect(200).Decode(&set) + for _, k := range set.Keys { + if k.Kid == kid { + n, _ := base64.RawURLEncoding.DecodeString(k.N) + eb, _ := base64.RawURLEncoding.DecodeString(k.E) + return &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(eb).Int64())} + } + } + t.Fatalf("kid %s not in JWKS", kid) + return nil +} + +func decodeJWT(t *testing.T, e *testenv.Env, tok string) map[string]any { + parts := strings.Split(tok, ".") + hb, _ := base64.RawURLEncoding.DecodeString(parts[0]) + var h struct{ Kid string } + _ = json.Unmarshal(hb, &h) + key := jwksKey(t, e, h.Kid) + var claims map[string]any + if err := auth.VerifyJWT(tok, map[string]*rsa.PublicKey{h.Kid: key}, &claims); err != nil { + t.Fatalf("app token doesn't verify against the published JWKS: %v", err) + } + return claims +} + +func TestAppTokenAndJWKS(t *testing.T) { + e := testenv.New(t) + u := e.Signup() + app := e.App("usr_01JAG0SYSTEM00000000000000", nil) + e.Post("/v1/auth/app-tokens", u.Token, map[string]any{"application_id": app}).ExpectErr(403, "entitlement_required") + e.Grant(e.Admin, u.ID, app) + r := e.Post("/v1/auth/app-tokens", u.Token, map[string]any{"application_id": app}).Expect(200) + claims := decodeJWT(t, e, r.Str("app_token")) + if claims["aud"] != app || claims["sub"] != u.ID || claims["entitlement_status"] != "active" || claims["org_id"] != nil { + t.Fatalf("claims = %v", claims) + } + // default_app_role (member) is held implicitly, with no assignment row. + if _, ok := claims["effective_permissions"].([]any); !ok { + t.Fatalf("effective_permissions = %v", claims["effective_permissions"]) + } + // API Keys can't mint app tokens. + key := e.Post("/v1/api-keys", e.Admin, map[string]any{"name": "k", "scope": "platform", "permissions": []string{"users.list"}}). + Expect(201).Str("secret") + e.Post("/v1/auth/app-tokens", key, map[string]any{"application_id": app}).ExpectErr(400, "user_token_required") + oc := e.Get("/.well-known/openid-configuration", "").Expect(200).JSON() + if oc["token_endpoint"] == nil || oc["authorization_endpoint"] == nil { + t.Fatalf("openid-configuration = %v", oc) + } +} + +func TestOAuthPKCE(t *testing.T) { + e := testenv.New(t) + u := e.Signup() + redirect := "com.example.app:/oauth/callback" + app := e.App("usr_01JAG0SYSTEM00000000000000", map[string]any{"redirect_uris": []string{redirect}}) + e.Grant(e.Admin, u.ID, app) + verifier := strings.Repeat("v", 50) + testenv.Unique() + sum := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) + e.Post("/v1/auth/oauth/authorization-codes", u.Token, map[string]any{"application_id": app, + "redirect_uri": "https://evil.example.com/cb", "code_challenge": challenge, "code_challenge_method": "S256"}). + ExpectErr(422, "redirect_uri_not_registered") + code := e.Post("/v1/auth/oauth/authorization-codes", u.Token, map[string]any{"application_id": app, + "redirect_uri": redirect, "code_challenge": challenge, "code_challenge_method": "S256", "state": "xyz"}).Expect(201) + if !strings.Contains(code.Str("redirect_to"), "state=xyz") { + t.Fatalf("redirect_to = %s", code.Str("redirect_to")) + } + // Form-encoded, as OAuth libraries send it. + form := url.Values{"grant_type": {"authorization_code"}, "client_id": {app}, "code": {code.Str("code")}, + "code_verifier": {verifier}, "redirect_uri": {redirect}} + tok := e.Do(testenv.Req{Method: "POST", Path: "/v1/auth/oauth/token", Body: form.Encode(), + Header: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}}).Expect(200) + if decodeJWT(t, e, tok.Str("app_token"))["aud"] != app { + t.Fatal("wrong audience") + } + // The code is single-use. + e.Post("/v1/auth/oauth/token", "", map[string]any{"grant_type": "authorization_code", "client_id": app, + "code": code.Str("code"), "code_verifier": verifier, "redirect_uri": redirect}).Expect(400) + // Refresh re-checks access every time. + ref := e.Post("/v1/auth/oauth/token", "", map[string]any{"grant_type": "refresh_token", "client_id": app, + "refresh_token": tok.Str("refresh_token")}).Expect(200) + ent := e.Get("/v1/users/"+u.ID+"/entitlements", e.Admin).JSON()["data"].([]any)[0].(map[string]any)["id"].(string) + e.Patch("/v1/entitlements/"+ent, e.Admin, map[string]any{"status": "disabled", "disabled_reason": "test"}).Expect(200) + denied := e.Post("/v1/auth/oauth/token", "", map[string]any{"grant_type": "refresh_token", "client_id": app, + "refresh_token": ref.Str("refresh_token")}).Expect(400) + if denied.Str("error") != "access_denied" { + t.Fatalf("refresh after revoke = %s", denied.Body) + } +} + +func TestSessions(t *testing.T) { + e := testenv.New(t) + u := e.Signup() + other := e.Login(u.Email, u.Password) + list := e.Get("/v1/users/me/sessions", u.Token).Expect(200).JSON()["data"].([]any) + if len(list) != 2 { + t.Fatalf("sessions = %d", len(list)) + } + e.Delete("/v1/users/me/sessions/"+other.Str("session_id"), u.Token).Expect(204) + e.Get("/v1/users/me", other.Token()).ExpectErr(401, "session_revoked") + e.Get("/v1/users/me", u.Token).Expect(200) +} + +func TestTestModeIsolation(t *testing.T) { + e := testenv.New(t) + app := e.App("usr_01JAG0SYSTEM00000000000000", nil) + testKey := e.Post("/v1/api-keys", e.Admin, map[string]any{"name": "qa", "scope": app, "mode": "test", + "permissions": []string{"entitlements.manage", "users.list"}}).Expect(201).Str("secret") + if !strings.HasPrefix(testKey, "satk_test_") { + t.Fatalf("secret = %s", testKey) + } + liveKey := e.Post("/v1/api-keys", e.Admin, map[string]any{"name": "live", "scope": "platform", + "permissions": []string{"users.list", "users.manage"}}).Expect(201).Str("secret") + addr := "qa+" + testenv.Unique() + "@example.com" + // A test key needs users.manage to create users; use a platform test key. + platformTest := e.Post("/v1/api-keys", e.Admin, map[string]any{"name": "qa-platform", "scope": "platform", "mode": "test", + "permissions": []string{"users.manage", "users.list", "entitlements.manage"}}).Expect(201).Str("secret") + tu := e.Post("/v1/users", platformTest, map[string]any{"email": addr, "status": "active", "send_invitation": false}).Expect(201) + if tu.JSON()["test_mode"] != true { + t.Fatalf("user = %s", tu.Body) + } + // A live user with the same email can coexist. + e.Post("/v1/users", liveKey, map[string]any{"email": addr, "status": "active", "send_invitation": false}).Expect(201) + // Live credentials never see the test user, and vice versa. + live := e.Get("/v1/users?email="+url.QueryEscape(addr), liveKey).Expect(200).JSON()["data"].([]any) + if len(live) != 1 || live[0].(map[string]any)["test_mode"] != false { + t.Fatalf("live view = %v", live) + } + e.Get("/v1/users/"+tu.Str("id"), liveKey).ExpectErr(404, "user_not_found") + ent := e.PostIdem("/v1/users/"+tu.Str("id")+"/entitlements", testKey, testenv.Unique(), + map[string]any{"application_id": app, "source": "trial", "ends_at": time.Now().Add(48 * time.Hour).Format(time.RFC3339)}).Expect(201) + if ent.JSON()["test_mode"] != true { + t.Fatalf("entitlement = %s", ent.Body) + } + // Test sign-in. + e.Post("/v1/auth/password/forgot", "", map[string]any{"email": addr, "test_mode": true}).Expect(202) + e.Post("/v1/auth/password/reset", "", map[string]any{"token": e.Token(addr, "password_reset"), "new_password": "test mode passphrase"}).Expect(204) + s := e.Post("/v1/auth/login", "", map[string]any{"email": addr, "password": "test mode passphrase", "test_mode": true}).Expect(200) + if s.Str("user_id") != tu.Str("id") { + t.Fatal("test-mode login matched the wrong account") + } + claims := decodeJWT(t, e, e.Post("/v1/auth/app-tokens", s.Token(), map[string]any{"application_id": app}).Expect(200).Str("app_token")) + if claims["test_mode"] != true { + t.Fatalf("app token test_mode = %v", claims["test_mode"]) + } +} diff --git a/internal/core/bootstrap.go b/internal/core/bootstrap.go new file mode 100644 index 0000000..9ac9e2c --- /dev/null +++ b/internal/core/bootstrap.go @@ -0,0 +1,61 @@ +package core + +import ( + "context" + "fmt" + + "github.com/Adron/substratalapps.com/internal/auth" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/ids" +) + +// BootstrapSuperadmin creates (or promotes) the first superadmin directly +// against the database. The public API can't, by design: nothing can hold +// roles.manage before a superadmin exists to grant it (NFR → +// Authentication → Bootstrapping). Only cmd/admin and tests call this. +func (s *Server) BootstrapSuperadmin(ctx context.Context, email, password string) (string, error) { + addr, ok := validEmail(email) + if !ok { + return "", fmt.Errorf("bootstrap: invalid email %q", email) + } + if err := auth.CheckPolicy(password, addr); err != nil { + return "", fmt.Errorf("bootstrap: %w", err) + } + hash, err := auth.HashPassword(password) + if err != nil { + return "", err + } + var userID string + err = s.system(ctx, false, db.Settings{}, func(c *Call) error { + u, err := c.findUser("u.email = :e and u.deleted_at is null", db.Args{"e": addr}) + switch err { + case nil: + userID = u.ID + if _, err := c.setPassword(u.ID, hash); err != nil { + return err + } + if _, err := c.q.Exec(ctx, `update users set status = 'active', email_verified = true where id = :u`, db.Args{"u": u.ID}); err != nil { + return err + } + case db.ErrNotFound: + nu, err := c.createUser(newUser{Email: addr, Status: "active", EmailVerify: true, PasswordHash: hash, + ActorType: "system", ActorID: "system"}) + if err != nil { + return err + } + userID = nu.ID + default: + return err + } + if _, err := c.q.Exec(ctx, `insert into user_role_assignments (user_id, role_id, application_id, assigned_by_type, assigned_by_id) + values (:u, 'role_platform_superadmin', null, 'system', 'system') on conflict do nothing`, db.Args{"u": userID}); err != nil { + return err + } + return c.audit(Audit{Action: "role.assigned", TargetType: "role_assignment", TargetID: userID + ":role_platform_superadmin", + TargetUserID: &userID, ActorType: "system", ActorID: "system", + After: map[string]any{"user_id": userID, "role_id": "role_platform_superadmin", "application_id": nil}}) + }) + return userID, err +} + +var _ = ids.New diff --git a/internal/core/call.go b/internal/core/call.go new file mode 100644 index 0000000..6e37ea7 --- /dev/null +++ b/internal/core/call.go @@ -0,0 +1,389 @@ +package core + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" +) + +// opts describe one operation's place in the pipeline. +type opts struct { + op string // operationId from docs/openapi.yaml + public bool // no bearer credential (auth endpoints, health) + userOnly bool // a User token is required; API Keys get 400 user_token_required + idem idemMode // Idempotency-Key handling +} + +type idemMode int + +const ( + idemNone idemMode = iota + idemOptional + idemRequired +) + +// Call is one in-flight request: its principal, its transaction, and the +// response it will write once that transaction commits. +type Call struct { + s *Server + ctx context.Context + r *http.Request + op opts + q db.Querier + p *Principal + now time.Time + + raw []byte // request body, read once + + status int + body any + etag int + headers http.Header + + commitOnError bool + afterCommit []func(context.Context) + ip string + requestID string + // sysTestMode is the mode a system (job) call runs in; there's no + // principal to read it from. + sysTestMode bool +} + +// serve runs fn as one operation. Every handler is a thin call to serve. +func (s *Server) serve(w http.ResponseWriter, r *http.Request, o opts, fn func(c *Call) error) { + ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) + defer cancel() + c := &Call{ + s: s, ctx: ctx, r: r, op: o, now: s.Now().UTC(), + headers: http.Header{}, ip: clientIP(r), requestID: RequestID(r.Context()), + } + raw, err := httpx.ReadBody(r) + if err != nil { + s.writeErr(w, c, err) + return + } + c.raw = raw + r.Body = io.NopCloser(bytes.NewReader(raw)) + + if o.public { + if err := c.rateLimitPublic(); err != nil { + s.writeErr(w, c, err) + return + } + } + + var fnErr error + txErr := s.DB.Tx(ctx, db.Settings{AllModes: !o.public}, func(q db.Querier) error { + c.q = q + if !o.public { + // The transaction opens in "any" mode only long enough to + // resolve the credential by its hash; it's pinned to the + // principal's own mode before any other statement runs. + p, err := s.authenticate(c) + if err != nil { + fnErr = err + return err + } + c.p = p + if err := c.setMode(p.TestMode); err != nil { + return err + } + if o.userOnly && !p.IsUser() { + fnErr = httpx.ErrUserTokenRequired() + return fnErr + } + if err := c.rateLimitPrincipal(); err != nil { + fnErr = err + return err + } + if err := c.destructiveAlways(); err != nil { + fnErr = err + return err + } + } + replayed, err := c.idempotencyBegin() + if err != nil { + fnErr = err + return err + } + if replayed { + return nil + } + if err := fn(c); err != nil { + fnErr = err + if c.commitOnError { + return nil + } + return err + } + return c.idempotencyFinish() + }) + if fnErr != nil { + if txErr != nil && !errors.Is(txErr, fnErr) && !isAPIError(fnErr) { + s.Log.Error("transaction failed", "op", o.op, "request_id", c.requestID, "err", txErr) + } + // A handler that committed despite its error (lockout counters, a + // re-sent invitation) still gets its post-commit effects. + if c.commitOnError && txErr == nil { + for _, f := range c.afterCommit { + f(context.WithoutCancel(ctx)) + } + } + s.writeErr(w, c, fnErr) + return + } + if txErr != nil { + if ce, ok := uniqueOrCheck(txErr); ok { + s.writeErr(w, c, ce) + return + } + s.Log.Error("transaction failed", "op", o.op, "request_id", c.requestID, "err", txErr) + s.writeErr(w, c, httpx.ErrInternal()) + return + } + for _, f := range c.afterCommit { + f(context.WithoutCancel(ctx)) + } + c.write(w) +} + +func isAPIError(err error) bool { + var e *httpx.Error + return errors.As(err, &e) +} + +// uniqueOrCheck turns a constraint failure that escaped a handler into a +// 409/422 rather than a 500. Handlers check the cases the spec names +// explicitly; this is the backstop for races between the check and insert. +func uniqueOrCheck(err error) (*httpx.Error, bool) { + if name, ok := db.UniqueViolation(err); ok { + return httpx.Conflict("conflict", "The request conflicts with an existing record.").With("constraint", name), true + } + return nil, false +} + +func (s *Server) writeErr(w http.ResponseWriter, c *Call, err error) { + var e *httpx.Error + if !errors.As(err, &e) { + if ce, ok := uniqueOrCheck(err); ok { + e = ce + } else { + s.Log.Error("request failed", "op", c.op.op, "request_id", c.requestID, "err", err) + e = httpx.ErrInternal() + } + } + if e.Status == 401 || e.Status == 403 || e.Status == 429 { + s.securityLog(c, e) + } + for k, v := range c.headers { + w.Header()[k] = v + } + httpx.WriteError(w, e) +} + +// securityLog is NFR → Security logging: structured, never a secret. +func (s *Server) securityLog(c *Call, e *httpx.Error) { + actor := "" + if c.p != nil { + actor = c.p.ID + } + s.Log.Warn("security", "event", "denied", "op", c.op.op, "code", e.Code, "status", e.Status, + "request_id", c.requestID, "actor", actor, "ip", c.ip, "user_agent", c.r.UserAgent()) +} + +func (c *Call) write(w http.ResponseWriter) { + for k, v := range c.headers { + w.Header()[k] = v + } + if c.etag > 0 { + w.Header().Set("ETag", httpx.ETag(c.etag)) + } + switch { + case c.status == http.StatusNoContent: + w.WriteHeader(http.StatusNoContent) + case c.status == http.StatusAccepted && c.body == nil: + w.WriteHeader(http.StatusAccepted) + default: + if c.status == 0 { + c.status = http.StatusOK + } + httpx.JSON(w, c.status, c.body) + } +} + +// ── Response helpers ─────────────────────────────────────────────────── + +// OK queues a 200 with body. +func (c *Call) OK(body any) error { c.status, c.body = http.StatusOK, body; return nil } + +// Created queues a 201 with body. +func (c *Call) Created(body any) error { c.status, c.body = http.StatusCreated, body; return nil } + +// Accepted queues a 202 (body may be nil). +func (c *Call) Accepted(body any) error { c.status, c.body = http.StatusAccepted, body; return nil } + +// NoContent queues a 204. +func (c *Call) NoContent() error { c.status = http.StatusNoContent; return nil } + +// Versioned sets the response ETag from a row version. +func (c *Call) Versioned(version int) *Call { c.etag = version; return c } + +// After runs f once the transaction has committed (emails, queue nudges). +func (c *Call) After(f func(ctx context.Context)) { c.afterCommit = append(c.afterCommit, f) } + +// CommitAnyway makes the transaction commit even though fn returns an +// error: failed-login counters and token invalidation must persist. +func (c *Call) CommitAnyway() { c.commitOnError = true } + +// Decode decodes the JSON body into dst. +func (c *Call) Decode(dst any) error { return httpx.DecodeBytes(c.raw, dst) } + +// Patch decodes the body preserving field presence. +func (c *Call) Patch() (httpx.Patch, error) { + if len(bytes.TrimSpace(c.raw)) == 0 { + return httpx.Patch{}, nil + } + var p httpx.Patch + if err := json.Unmarshal(c.raw, &p); err != nil { + return nil, httpx.Invalid("The request body isn't a JSON object.") + } + return p, nil +} + +// setMode pins the transaction's test/live mode (RLS). With no principal +// (public auth endpoints, jobs) it's also the mode new rows are written in. +func (c *Call) setMode(testMode bool) error { + if c.p == nil { + c.sysTestMode = testMode + } + _, err := c.q.Query(c.ctx, `select to_jsonb(set_config('app.current_test_mode', :tm, true))`, + db.Args{"tm": db.Settings{TestMode: testMode}.ModeSetting()}) + return err +} + +// testMode is the mode the request operates in. +func (c *Call) testMode() bool { + if c.p != nil { + return c.p.TestMode + } + return c.sysTestMode +} + +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + if host == "" || host == "127.0.0.1" || host == "::1" { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + host = strings.TrimSpace(strings.Split(xff, ",")[0]) + } + } + return host +} + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// ── Idempotency (Conventions → Idempotency) ──────────────────────────── + +func (c *Call) callerID() string { + if c.p != nil { + return c.p.ID + } + return "ip:" + c.ip +} + +// idempotencyBegin claims (caller, key) in this transaction. A concurrent +// request with the same key blocks on the row lock until this one commits, +// then sees the completed row and replays it. +func (c *Call) idempotencyBegin() (replayed bool, err error) { + key := c.r.Header.Get("Idempotency-Key") + if c.op.idem == idemNone || key == "" { + if c.op.idem == idemRequired { + return false, httpx.ErrIdempotencyRequired() + } + return false, nil + } + if len(key) > 255 { + var f httpx.Fields + f.Max("Idempotency-Key", "too_long", 255) + return false, f.Err() + } + hash := sha256Hex(append([]byte(c.op.op+"\n"+c.r.URL.Path+"\n"), c.raw...)) + n, err := c.q.Exec(c.ctx, `insert into idempotency_keys (caller_id, idempotency_key, state, request_body_hash, expires_at) + values (:caller, :key, 'in_flight', :hash, :exp::timestamptz) + on conflict (caller_id, idempotency_key) do update + set state = 'in_flight', request_body_hash = excluded.request_body_hash, + response_status = null, response_body = null, expires_at = excluded.expires_at + where idempotency_keys.expires_at < now()`, + db.Args{"caller": c.callerID(), "key": key, "hash": hash, "exp": db.Time(c.now.Add(24 * time.Hour))}) + if err != nil { + return false, err + } + if n == 1 { + return false, nil + } + var prev struct { + State string `json:"state"` + Hash string `json:"request_body_hash"` + Status int `json:"response_status"` + Body json.RawMessage `json:"response_body"` + } + if err := db.One(c.ctx, c.q, &prev, `select to_jsonb(k) from idempotency_keys k + where caller_id = :caller and idempotency_key = :key`, db.Args{"caller": c.callerID(), "key": key}); err != nil { + return false, err + } + switch { + case prev.Hash != hash: + return false, httpx.Conflict("idempotency_key_reused", "This Idempotency-Key was already used with a different request body.") + case prev.State != "completed": + return false, httpx.Conflict("idempotency_key_in_flight", "The first request with this Idempotency-Key is still being processed. Retry after a second.").RetryAfter(1) + } + c.headers.Set("Idempotent-Replayed", "true") + c.status = prev.Status + if len(prev.Body) > 0 && string(prev.Body) != "null" { + c.body = prev.Body + } + return true, nil +} + +func (c *Call) idempotencyFinish() error { + key := c.r.Header.Get("Idempotency-Key") + if c.op.idem == idemNone || key == "" { + return nil + } + status := c.status + if status == 0 { + status = http.StatusOK + } + var body any + if c.body != nil { + b, err := json.Marshal(c.body) + if err != nil { + return err + } + body = string(b) + } + _, err := c.q.Exec(c.ctx, `update idempotency_keys set state = 'completed', response_status = :st, response_body = :body::jsonb + where caller_id = :caller and idempotency_key = :key`, + db.Args{"st": int64(status), "body": body, "caller": c.callerID(), "key": key}) + return err +} + +// errf is fmt.Errorf, for internal (500) errors with context. +func errf(format string, a ...any) error { return fmt.Errorf(format, a...) } diff --git a/internal/core/common.go b/internal/core/common.go new file mode 100644 index 0000000..d736155 --- /dev/null +++ b/internal/core/common.go @@ -0,0 +1,350 @@ +package core + +import ( + "context" + "encoding/json" + "time" + + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/ids" +) + +// contextT is context.Context, for the post-commit hooks registered with After. +type contextT = context.Context + +// ── Audit (NFR → Audit; Orders & Audit → Audit Event) ────────────────── + +// Audit is one Audit Event to write in the current transaction. +type Audit struct { + Action string + TargetType string + TargetID string + TargetUserID *string + ApplicationID *string + OrganizationID *string + TenantID *string + Before, After any + // Actor overrides the caller (system jobs). + ActorType, ActorID string +} + +// audit writes an Audit Event in the request's transaction, so the change +// and its record commit together or not at all. +func (c *Call) audit(a Audit) error { + typ, id := c.actor() + if a.ActorType != "" { + typ, id = a.ActorType, a.ActorID + } + return writeAudit(c, c.q, a, typ, id, c.requestID, c.testMode()) +} + +func writeAudit(c *Call, q db.Querier, a Audit, actorType, actorID, requestID string, testMode bool) error { + var before, after any + if a.Before != nil { + before = db.JSON(a.Before) + } + if a.After != nil { + after = db.JSON(a.After) + } + var rid any + if requestID != "" { + rid = requestID + } + _, err := q.Exec(c.ctx, `insert into audit_events (id, action, actor_type, actor_id, target_type, target_id, + target_user_id, application_id, organization_id, tenant_id, before, after, request_id, test_mode) + values (:id, :action, :atype, :aid, :ttype, :tid, :tuser, :app, :org, + coalesce(:tenant, (select tenant_id from applications where id = :app)), + :before::jsonb, :after::jsonb, :rid, :tm)`, + db.Args{"id": ids.New(ids.AuditEvent), "action": a.Action, "atype": actorType, "aid": actorID, + "ttype": a.TargetType, "tid": a.TargetID, "tuser": a.TargetUserID, "app": a.ApplicationID, + "org": a.OrganizationID, "tenant": a.TenantID, "before": before, "after": after, + "rid": rid, "tm": testMode}) + return err +} + +// ── Webhook outbox (Webhooks → Delivery) ─────────────────────────────── + +// webhookAPIVersion is the current (and only) payload version. +const webhookAPIVersion = "2026-10-05" + +// emit inserts a webhook event into the transactional outbox, so a +// committed change can never fail to produce its event, and nudges the +// delivery pipeline once the transaction commits. +func (c *Call) emit(eventType string, appID *string, data any) error { + return emitWith(c, c.q, eventType, appID, data, c.testMode()) +} + +func emitWith(c *Call, q db.Querier, eventType string, appID *string, data any, testMode bool) error { + id := ids.New(ids.WebhookEvent) + _, err := q.Exec(c.ctx, `insert into webhook_events (id, type, application_id, payload, test_mode) + values (:id, :type, :app, :payload::jsonb, :tm)`, + db.Args{"id": id, "type": eventType, "app": appID, "payload": db.JSON(data), "tm": testMode}) + if err != nil { + return err + } + c.After(func(ctx context.Context) { c.s.Notifier.EventCommitted(ctx, id) }) + return nil +} + +// ── Tenants & plan limits (Pricing → Enforcement) ────────────────────── + +type tenantRow struct { + ID string `json:"id"` + OwnerType string `json:"owner_type"` + OwnerUserID *string `json:"owner_user_id"` + OwnerOrganizationID *string `json:"owner_organization_id"` + Tier string `json:"tier"` + Plan string `json:"plan"` + Region string `json:"region"` + StripeCustomerID *string `json:"stripe_customer_id"` + StripeSubID *string `json:"stripe_subscription_id"` + SubscriptionStatus string `json:"subscription_status"` + PeriodStart *time.Time `json:"current_period_start"` + PeriodEnd *time.Time `json:"current_period_end"` + CancelAtPeriodEnd bool `json:"cancel_at_period_end"` + Restricted bool `json:"restricted"` + SeatCountSynced *int `json:"seat_count_synced"` + SeatCountSyncedAt *time.Time `json:"seat_count_synced_at"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// planLimit is one plan's cap for a resource; nil means unlimited. +type planLimits struct { + Applications, AppRoles, Webhooks, Seats *int + SeatsIncluded int + AuditHotDays *int +} + +func ip(n int) *int { return &n } + +var plans = map[string]planLimits{ + "starter": {Applications: ip(1), AppRoles: ip(3), Webhooks: ip(1), Seats: ip(1000), SeatsIncluded: 1000, AuditHotDays: ip(30)}, + "team": {Applications: ip(5), Webhooks: ip(10), SeatsIncluded: 25, AuditHotDays: ip(365)}, + "enterprise": {SeatsIncluded: 500}, +} + +func planLimitErr(resource string, limit, current int, t tenantRow) error { + msg := map[string]string{ + "applications": "This Tenant's plan doesn't allow more Applications.", + "app_roles": "This Tenant's plan doesn't allow more AppRoles on one Application.", + "webhooks": "This Tenant's plan doesn't allow more webhook subscriptions.", + "seats": "This Tenant's plan doesn't allow more seats.", + }[resource] + return httpx.E(409, "plan_limit_reached", msg). + With("resource", resource).With("limit", limit).With("current", current). + With("plan", t.Plan).With("tenant_id", t.ID) +} + +func subscriptionRequired(t tenantRow) error { + return httpx.E(402, "subscription_required", + "This Tenant's subscription has lapsed; resubscribe to add usage.").With("tenant_id", t.ID) +} + +func (c *Call) loadTenant(id string) (tenantRow, error) { + var t tenantRow + err := db.One(c.ctx, c.q, &t, `select to_jsonb(t) from tenants t where id = :id`, db.Args{"id": id}) + if err == db.ErrNotFound { + return t, httpx.NotFoundResource("tenant") + } + return t, err +} + +// tenantWritable enforces Tenant.status on a write to the Tenant's data: +// suspended → 403 tenant_suspended, migrating → 503 with Retry-After. +func tenantWritable(t tenantRow) error { + switch t.Status { + case "suspended": + return httpx.E(403, "tenant_suspended", "This Tenant is suspended by the platform.").With("tenant_id", t.ID) + case "migrating": + return httpx.E(503, "service_unavailable", "This Tenant is mid-migration; writes are paused."). + With("tenant_id", t.ID).RetryAfter(300) + } + return nil +} + +// ensureTenant resolves (creating on first use) the Tenant for an +// Application owner: tier shared, plan starter for a User owner and team +// for an Organization owner (Tenancy → What it represents). +func (c *Call) ensureTenant(ownerUserID, ownerOrgID *string) (tenantRow, error) { + var t tenantRow + var err error + if ownerUserID != nil { + err = db.One(c.ctx, c.q, &t, `select to_jsonb(t) from tenants t where owner_user_id = :u`, db.Args{"u": *ownerUserID}) + } else { + err = db.One(c.ctx, c.q, &t, `select to_jsonb(t) from tenants t where owner_organization_id = :o`, db.Args{"o": *ownerOrgID}) + } + if err != db.ErrNotFound { + return t, err + } + ownerType, plan := "user", "starter" + if ownerOrgID != nil { + ownerType, plan = "organization", "team" + } + err = db.One(c.ctx, c.q, &t, `insert into tenants (id, owner_type, owner_user_id, owner_organization_id, plan) + values (:id, :type, :u, :o, :plan) returning to_jsonb(tenants)`, + db.Args{"id": ids.New(ids.Tenant), "type": ownerType, "u": ownerUserID, "o": ownerOrgID, "plan": plan}) + if err != nil { + return t, err + } + c.After(func(ctx context.Context) { c.s.provisionStripeCustomer(ctx, t.ID) }) + return t, nil +} + +// ── Users ────────────────────────────────────────────────────────────── + +type userRow struct { + ID string `json:"id"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + PendingEmail *string `json:"pending_email"` + Status string `json:"status"` + MFAEnabled bool `json:"mfa_enabled"` + SignupApplicationID *string `json:"signup_application_id"` + TestMode bool `json:"test_mode"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastLoginAt *time.Time `json:"last_login_at"` + Version int `json:"-"` + DeletedAt *time.Time `json:"-"` +} + +// userRowSQL selects a user with its computed mfa_enabled, as the API +// User object plus version/deleted_at. +const userRowSQL = `select to_jsonb(u) || jsonb_build_object( + 'mfa_enabled', exists (select 1 from user_identities i where i.user_id = u.id and i.mfa_enabled), + 'version_', u.version, 'deleted_at_', u.deleted_at) from users u` + +type userRowDB struct { + userRow + V int `json:"version_"` + DA *time.Time `json:"deleted_at_"` +} + +func decodeUser(raw []byte) (userRow, error) { + var r userRowDB + if err := json.Unmarshal(raw, &r); err != nil { + return userRow{}, err + } + r.userRow.Version, r.userRow.DeletedAt = r.V, r.DA + return r.userRow, nil +} + +func (c *Call) findUser(where string, args db.Args) (userRow, error) { + rows, err := c.q.Query(c.ctx, userRowSQL+" where "+where, args) + if err != nil { + return userRow{}, err + } + if len(rows) == 0 { + return userRow{}, db.ErrNotFound + } + return decodeUser(rows[0]) +} + +// visibleUser loads a User by id for the caller: a soft-deleted User 404s +// for everyone but users.manage (Conventions → Filtering). +func (c *Call) visibleUser(id string) (userRow, error) { + u, err := c.findUser("u.id = :id", db.Args{"id": id}) + if err == db.ErrNotFound || (err == nil && u.Status == "deleted" && !c.can("users.manage")) { + return u, httpx.NotFoundResource("user") + } + return u, err +} + +// ── Applications ─────────────────────────────────────────────────────── + +type appRow struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description *string `json:"description"` + IconURL *string `json:"icon_url"` + LaunchURL string `json:"launch_url"` + RedirectURIs []string `json:"redirect_uris"` + SupportURL *string `json:"support_url"` + EmailFromName *string `json:"email_from_name"` + SettingsSchema json.RawMessage `json:"settings_schema"` + Permissions []appPermission `json:"permissions"` + AvailableAppRoles []string `json:"available_app_roles"` + DefaultAppRole *string `json:"default_app_role"` + Visibility string `json:"visibility"` + OwnerUserID *string `json:"owner_user_id"` + OwnerOrganizationID *string `json:"owner_organization_id"` + ReviewStatus string `json:"review_status"` + ReviewNotes *string `json:"review_notes"` + TenantID string `json:"tenant_id"` + TestMode bool `json:"test_mode"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` +} + +type appPermission struct { + Key string `json:"key"` + Description *string `json:"description,omitempty"` +} + +func (c *Call) loadApp(id string) (appRow, error) { + var a appRow + err := db.One(c.ctx, c.q, &a, `select to_jsonb(a) from applications a where id = :id`, db.Args{"id": id}) + return a, err +} + +// isAppOwner: the owner_user_id User, or any active org_admin of +// owner_organization_id, acting with a User token. +func (c *Call) isAppOwner(a appRow) (bool, error) { + if c.p == nil || !c.p.IsUser() { + return false, nil + } + if a.OwnerUserID != nil { + return *a.OwnerUserID == c.p.UserID, nil + } + if a.OwnerOrganizationID != nil { + return c.isOrgAdmin(*a.OwnerOrganizationID) + } + return false, nil +} + +// isOrgAdmin reports whether the calling User is an active org_admin. +func (c *Call) isOrgAdmin(orgID string) (bool, error) { + if c.p == nil || !c.p.IsUser() { + return false, nil + } + rows, err := c.q.Query(c.ctx, `select to_jsonb(true) from organization_memberships + where organization_id = :o and user_id = :u and role = 'org_admin' and status = 'active'`, + db.Args{"o": orgID, "u": c.p.UserID}) + return len(rows) > 0, err +} + +// canManageApp is "entitlements.manage (platform or app-confined), or the +// Application's owner" for a given permission, the pattern Entitlements, +// Roles, and Webhooks all share. +func (c *Call) canManageApp(perm string, a appRow) (bool, error) { + if c.can(perm) || c.appKeyCan(perm, a.ID) { + return true, nil + } + return c.isAppOwner(a) +} + +// userHasAccessPath reports whether a User has any access path (personal +// Entitlement in any status, or membership in an Organization holding a +// grant) to an Application, the visibility rule for app-confined callers. +func (c *Call) userHasAccessPath(userID, appID string) (bool, error) { + rows, err := c.q.Query(c.ctx, `select to_jsonb(true) where exists ( + select 1 from entitlements e where e.user_id = :u and e.application_id = :a) + or exists ( + select 1 from entitlements e join organization_memberships m on m.organization_id = e.organization_id + where m.user_id = :u and e.application_id = :a)`, db.Args{"u": userID, "a": appID}) + return len(rows) > 0, err +} + +func strp(s string) *string { return &s } + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/internal/core/destructive.go b/internal/core/destructive.go new file mode 100644 index 0000000..446cb4e --- /dev/null +++ b/internal/core/destructive.go @@ -0,0 +1,69 @@ +package core + +import "github.com/Adron/substratalapps.com/internal/httpx" + +// Destructive is the single classification of destructive operations +// (API Keys → Agent keys & restrict_destructive; Database Schema → api_keys +// "Enforcement"). It mirrors x-substratal-destructive in docs/openapi.yaml, +// and both the restrict_destructive gate here and the MCP server's +// destructiveHint read it, so the two can't drift. A test checks it +// against the spec. +// +// Always: every request is destructive. Otherwise When describes the +// condition, and the handler calls destructiveIf with the evaluated value. +type Destructive struct { + Always bool + When string +} + +// DestructiveOps maps operationId → classification. +var DestructiveOps = map[string]Destructive{ + "auth.logout": {When: "all_sessions is true"}, + "auth.resetMfaTotp": {Always: true}, + "auth.revokeSession": {Always: true}, + "users.update": {When: "status becomes suspended"}, + "users.delete": {Always: true}, + "users.suspend": {Always: true}, + "users.requestErasure": {Always: true}, + "users.cancelErasure": {Always: true}, + "applications.update": {When: "review_status becomes rejected or suspended, or available_app_roles/permissions shrink"}, + "entitlements.update": {When: "status becomes disabled or revoked"}, + "entitlements.delete": {Always: true}, + "roles.update": {When: "permissions shrink"}, + "roles.delete": {Always: true}, + "roles.remove": {Always: true}, + "organizations.update": {When: "status becomes suspended"}, + "organizations.updateMember": {When: "demotes an org_admin"}, + "organizations.removeMember": {Always: true}, + "tenants.requestTierChange": {Always: true}, + "tenants.updateTierChangeRequest": {Always: true}, + "webhooks.delete": {Always: true}, + "apiKeys.update": {When: "restrict_destructive set to false"}, + "apiKeys.delete": {Always: true}, + "apiKeys.rotate": {Always: true}, +} + +func restricted(c *Call) error { + return httpx.E(403, "destructive_operation_restricted", + "This API Key has restrict_destructive enabled and cannot perform this operation.").With("key_id", c.p.ID) +} + +// destructiveAlways rejects an always-destructive op for a restricted key. +func (c *Call) destructiveAlways() error { + if c.p == nil || !c.p.RestrictDestructive { + return nil + } + if d, ok := DestructiveOps[c.op.op]; ok && d.Always { + return restricted(c) + } + return nil +} + +// destructiveIf rejects a conditional-destructive request whose condition +// holds, for a restricted key. +func (c *Call) destructiveIf(cond bool) error { + if cond && c.p != nil && c.p.RestrictDestructive { + return restricted(c) + } + return nil +} diff --git a/internal/core/entitlements.go b/internal/core/entitlements.go new file mode 100644 index 0000000..c1dcf5c --- /dev/null +++ b/internal/core/entitlements.go @@ -0,0 +1,919 @@ +package core + +import ( + "encoding/json" + "net/http" + "slices" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/access" + "github.com/Adron/substratalapps.com/internal/api/gen" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/ids" +) + +type grantedVia struct { + OrganizationID string `json:"organization_id"` + MemberDecision string `json:"member_decision"` +} + +// entitlement is the API Entitlement object. +type entitlement struct { + ID string `json:"id"` + UserID *string `json:"user_id"` + OrganizationID *string `json:"organization_id"` + ApplicationID string `json:"application_id"` + Status string `json:"status"` + Source string `json:"source"` + OrderID *string `json:"order_id"` + StartsAt time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at"` + DisabledReason *string `json:"disabled_reason"` + MemberScope *string `json:"member_scope"` + MemberOverrides []string `json:"member_overrides"` + IncludedMemberCount *int `json:"included_member_count,omitempty"` + GrantedVia *grantedVia `json:"granted_via,omitempty"` + TestMode bool `json:"test_mode"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + version int + tenantID string +} + +// entSQL selects Entitlements as API objects plus version/tenant and, for +// org grants, how many active members the grant currently includes. +const entSQL = `select to_jsonb(e) || jsonb_build_object('version_', e.version, 'tenant_', e.tenant_id, + 'included_member_count', case when e.organization_id is not null then ( + select count(*) from organization_memberships m + where m.organization_id = e.organization_id and m.status = 'active' + and (coalesce(e.member_scope, 'all_members') = 'all_members' + or (e.member_scope = 'allowlist' and m.user_id = any(e.member_overrides)) + or (e.member_scope = 'denylist' and not (m.user_id = any(coalesce(e.member_overrides, '{}')))))) end) + from entitlements e` + +func decodeEnt(raw json.RawMessage) (entitlement, error) { + var x struct { + entitlement + V int `json:"version_"` + T string `json:"tenant_"` + } + if err := json.Unmarshal(raw, &x); err != nil { + return entitlement{}, err + } + x.entitlement.version, x.entitlement.tenantID = x.V, x.T + return x.entitlement, nil +} + +func (c *Call) queryEnts(where string, args db.Args) ([]entitlement, error) { + raw, err := c.q.Query(c.ctx, entSQL+" where "+where, args) + if err != nil { + return nil, err + } + out := make([]entitlement, 0, len(raw)) + for _, r := range raw { + e, err := decodeEnt(r) + if err != nil { + return nil, err + } + out = append(out, e) + } + return out, nil +} + +func (c *Call) loadEnt(id string) (entitlement, error) { + es, err := c.queryEnts("e.id = :id", db.Args{"id": id}) + if err != nil { + return entitlement{}, err + } + if len(es) == 0 { + return entitlement{}, httpx.NotFoundResource("entitlement") + } + return es[0], nil +} + +// eventEntitlement is the entitlement as it appears in webhook payloads +// and audit snapshots (no computed per-reader fields). +func (e entitlement) snapshot() entitlement { + e.GrantedVia = nil + return e +} + +// ── POST /v1/users/{id}/entitlements ─────────────────────────────────── + +func (s *Server) EntitlementsGrant(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe, params gen.EntitlementsGrantParams) { + s.serve(w, r, opts{op: "entitlements.grant", idem: idemRequired}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + var in struct { + ApplicationID string `json:"application_id"` + Source string `json:"source"` + OrderID *string `json:"order_id"` + StartsAt *time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at"` + } + if err := c.Decode(&in); err != nil { + return err + } + if in.ApplicationID == "" || in.Source == "" { + return httpx.Invalid("application_id and source are required.") + } + a, err := c.loadApp(in.ApplicationID) + if err == db.ErrNotFound { + return httpx.NotFoundResource("application") + } + if err != nil { + return err + } + if ok, err := c.canManageApp("entitlements.manage", a); err != nil { + return err + } else if !ok { + if _, isKey := c.p.AppKey(); isKey { + return httpx.NotFoundResource("application") + } + return httpx.Forbidden("entitlements.manage") + } + if err := c.grantable(a); err != nil { + return err + } + starts := c.now + if in.StartsAt != nil { + starts = in.StartsAt.UTC() + } + if err := validateTerm(in.Source, starts, in.EndsAt, in.OrderID, c.now); err != nil { + return err + } + u, err := c.visibleUser(uid) + if err != nil { + return err + } + if u.Status == "deleted" { + return httpx.NotFoundResource("user") + } + t, err := c.loadTenant(a.TenantID) + if err != nil { + return err + } + if err := tenantWritable(t); err != nil { + return err + } + live, err := c.queryEnts(`e.user_id = :u and e.application_id = :a and e.status in ('active','disabled')`, + db.Args{"u": uid, "a": a.ID}) + if err != nil { + return err + } + if len(live) > 0 { + return httpx.Conflict("entitlement_already_exists", "This user already has a live Entitlement to this Application."). + With("existing_entitlement_id", live[0].ID).With("status", live[0].Status) + } + if err := c.checkNewSeat(t, uid); err != nil { + return err + } + watch, err := c.watchAccess([]pair{{uid, a.ID}}) + if err != nil { + return err + } + eid := ids.New(ids.Entitlement) + if _, err := c.q.Exec(c.ctx, `insert into entitlements (id, user_id, application_id, tenant_id, status, source, + order_id, starts_at, ends_at, test_mode) + values (:id, :u, :a, :t, 'active', :src, :ord, :st::timestamptz, :en::timestamptz, :tm)`, + db.Args{"id": eid, "u": uid, "a": a.ID, "t": a.TenantID, "src": in.Source, "ord": in.OrderID, + "st": db.Time(starts), "en": db.NullTime(in.EndsAt), "tm": c.testMode()}); err != nil { + if _, ok := db.UniqueViolation(err); ok { + return httpx.Conflict("entitlement_already_exists", "This user already has a live Entitlement to this Application.") + } + return err + } + e, err := c.loadEnt(eid) + if err != nil { + return err + } + if err := c.entitlementEffects("entitlement.granted", nil, e, nil); err != nil { + return err + } + if err := c.flip(watch, "entitlement_granted", "entitlement_revoked"); err != nil { + return err + } + c.Versioned(e.version) + return c.Created(e) + }) +} + +// grantable: the Application must be approved, and an internal app only +// accepts grants from platform entitlements.manage. +func (c *Call) grantable(a appRow) error { + if a.ReviewStatus != "approved" { + return httpx.Conflict("application_not_available", "This Application isn't approved for new grants."). + With("review_status", a.ReviewStatus) + } + if a.Visibility == "internal" && !c.can("entitlements.manage") { + return httpx.Conflict("application_not_available", "Only platform entitlements.manage can grant an internal Application.") + } + return nil +} + +func validateTerm(source string, starts time.Time, ends *time.Time, orderID *string, now time.Time) error { + var f httpx.Fields + switch source { + case "purchase", "trial", "admin_grant": + case "org_seat": + return httpx.E(422, "invalid_source", "org_seat grants are created through POST /v1/organizations/{id}/entitlements.") + default: + f.Enum("source", "purchase", "trial", "admin_grant") + } + if source == "trial" && ends == nil { + return httpx.E(422, "ends_at_required", "A trial needs an ends_at.") + } + if starts.After(now.AddDate(1, 0, 0)) { + f.Add("starts_at", "out_of_range").Message = "at most 1 year ahead" + } + if ends != nil && !ends.After(starts) { + f.Add("ends_at", "out_of_range").Message = "must be after starts_at" + } + if orderID != nil && len(*orderID) > 255 { + f.Max("order_id", "too_long", 255) + } + return f.Err() +} + +// checkNewSeat enforces the Starter seat cap and restricted mode for a +// write that would make userID a new seat (Pricing → Enforcement). Test +// mode never consumes seats. +func (c *Call) checkNewSeat(t tenantRow, userID string) error { + if c.testMode() { + return nil + } + if !t.Restricted && plans[t.Plan].Seats == nil { + return nil + } + ss, err := c.seats(t.ID) + if err != nil { + return err + } + if ss.isSeat(userID) { + return nil + } + if t.Restricted { + return subscriptionRequired(t) + } + if l := plans[t.Plan].Seats; l != nil && ss.count() >= *l { + return planLimitErr("seats", *l, ss.count(), t) + } + return nil +} + +// entitlementEffects writes the audit event and resource webhook for one +// Entitlement change, in the same transaction. +func (c *Call) entitlementEffects(action string, before *entitlement, after entitlement, extra map[string]any) error { + app := after.ApplicationID + a := Audit{Action: action, TargetType: "entitlement", TargetID: after.ID, TargetUserID: after.UserID, + ApplicationID: &app, OrganizationID: after.OrganizationID, TenantID: strp(after.tenantID), After: after.snapshot()} + if before != nil { + a.Before = before.snapshot() + } + if err := c.audit(a); err != nil { + return err + } + data := map[string]any{"entitlement": after.snapshot()} + for k, v := range extra { + data[k] = v + } + return c.emit(action, &app, data) +} + +// affectedPairs: a personal row affects its holder; an org grant affects +// every member of the holding Organization. +func (c *Call) affectedPairs(e entitlement) ([]pair, error) { + if e.UserID != nil { + return []pair{{*e.UserID, e.ApplicationID}}, nil + } + return c.orgMemberPairs(*e.OrganizationID, e.ApplicationID) +} + +// ── Reading ──────────────────────────────────────────────────────────── + +// entAccess describes how the caller may act on one Entitlement. +type entAccess struct { + manage bool // entitlements.manage (platform/app-confined) or app owner + orgAdmin bool // org_admin of the holding Organization (org rows only) + read bool +} + +func (c *Call) entAccessFor(e entitlement) (entAccess, error) { + var ea entAccess + if app, ok := c.p.AppKey(); ok { + if app != e.ApplicationID { + return ea, nil + } + ea.manage = c.p.Perms["entitlements.manage"] + ea.read = ea.manage || c.p.Perms["users.list"] + return ea, nil + } + if c.can("entitlements.manage") { + ea.manage, ea.read = true, true + return ea, nil + } + a, err := c.loadApp(e.ApplicationID) + if err != nil { + return ea, err + } + if owner, err := c.isAppOwner(a); err != nil { + return ea, err + } else if owner { + ea.manage, ea.read = true, true + return ea, nil + } + if !c.p.IsUser() { + return ea, nil + } + if e.UserID != nil && *e.UserID == c.p.UserID { + ea.read = true + } + if e.OrganizationID != nil { + if admin, err := c.isOrgAdmin(*e.OrganizationID); err != nil { + return ea, err + } else if admin { + ea.orgAdmin, ea.read = true, true + } + if member, err := c.isActiveMember(*e.OrganizationID, c.p.UserID); err != nil { + return ea, err + } else if member { + ea.read = true + } + } + return ea, nil +} + +func (c *Call) isActiveMember(orgID, userID string) (bool, error) { + rows, err := c.q.Query(c.ctx, `select to_jsonb(true) from organization_memberships + where organization_id = :o and user_id = :u and status = 'active'`, db.Args{"o": orgID, "u": userID}) + return len(rows) > 0, err +} + +// withGrantedVia annotates an org row with one member's decision. +func (c *Call) withGrantedVia(e entitlement, userID string) (entitlement, error) { + if e.OrganizationID == nil { + return e, nil + } + res, err := c.resolveOne(userID, e.ApplicationID) + if err != nil { + return e, err + } + for _, p := range res.Paths { + if p.EntitlementID == e.ID { + e.GrantedVia = &grantedVia{OrganizationID: *e.OrganizationID, MemberDecision: access.MemberDecision(p, userID)} + } + } + return e, nil +} + +func (s *Server) EntitlementsGet(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "entitlements.get"}, func(c *Call) error { + e, err := c.loadEnt(id) + if err != nil { + return err + } + ea, err := c.entAccessFor(e) + if err != nil { + return err + } + if !ea.read { + return httpx.NotFoundResource("entitlement") + } + if e.OrganizationID != nil && c.p.IsUser() && !ea.manage { + if member, _ := c.isActiveMember(*e.OrganizationID, c.p.UserID); member { + if e, err = c.withGrantedVia(e, c.p.UserID); err != nil { + return err + } + } + } + c.Versioned(e.version) + return c.OK(e) + }) +} + +func (s *Server) EntitlementsListForUser(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe, params gen.EntitlementsListForUserParams) { + s.serve(w, r, opts{op: "entitlements.listForUser"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + appFilter := params.ApplicationId + if app, ok := c.p.AppKey(); ok { + if !c.p.Perms["entitlements.manage"] && !c.p.Perms["users.list"] { + return httpx.Forbidden("entitlements.manage") + } + appFilter = &app + } else if !c.isSelf(uid) && !c.can("entitlements.manage") { + return httpx.Forbidden("entitlements.manage") + } + if _, err := c.visibleUser(uid); err != nil { + return err + } + lim := httpx.Limit(params.Limit) + filters := httpx.Filters(c.r) + pos, err := c.s.Cursors.Decode(deref(params.Cursor), filters) + if err != nil { + return err + } + where := []string{`(e.user_id = :u or e.organization_id in (select organization_id from organization_memberships + where user_id = :u and status = 'active'))`} + args := db.Args{"u": uid, "lim": int64(lim + 1)} + where, args = entFilters(where, args, appFilter, enumStr(params.Status), enumStr(params.Source), params.IncludeInactive) + if pos != nil { + where = append(where, "(e.created_at, e.id) < (:ps::timestamptz, :pi)") + args["ps"], args["pi"] = pos.Sort, pos.ID + } + rows, err := c.queryEnts(strings.Join(where, " and ")+" order by e.created_at desc, e.id desc limit :lim", args) + if err != nil { + return err + } + for i := range rows { + if rows[i], err = c.withGrantedVia(rows[i], uid); err != nil { + return err + } + } + return c.OK(httpx.Paginate(c.s.Cursors, rows, lim, filters, func(e entitlement) (string, string) { + return httpx.TimeKey(e.CreatedAt), e.ID + })) + }) +} + +func entFilters(where []string, args db.Args, app, status, source *string, includeInactive *bool) ([]string, db.Args) { + if app != nil { + where = append(where, "e.application_id = :app") + args["app"] = *app + } + if status != nil { + where = append(where, "e.status = :status") + args["status"] = *status + } else if includeInactive == nil || !*includeInactive { + where = append(where, "e.status in ('active','disabled')") + } + if source != nil { + where = append(where, "e.source = :source") + args["source"] = *source + } + return where, args +} + +func enumStr[T ~string](p *T) *string { + if p == nil { + return nil + } + s := string(*p) + return &s +} + +func (s *Server) EntitlementsList(w http.ResponseWriter, r *http.Request, params gen.EntitlementsListParams) { + s.serve(w, r, opts{op: "entitlements.list"}, func(c *Call) error { + appFilter := params.ApplicationId + var where []string + args := db.Args{} + if app, ok := c.p.AppKey(); ok { + if !c.p.Perms["entitlements.manage"] { + return httpx.Forbidden("entitlements.manage") + } + appFilter = &app + } else if !c.can("entitlements.manage") { + if !c.p.IsUser() { + return httpx.Forbidden("entitlements.manage") + } + // An Application owner sees only their own Applications' rows. + where = append(where, `e.application_id in (select a.id from applications a where a.owner_user_id = :me + or a.owner_organization_id in (select organization_id from organization_memberships + where user_id = :me and role = 'org_admin' and status = 'active'))`) + args["me"] = c.p.UserID + } + lim := httpx.Limit(params.Limit) + args["lim"] = int64(lim + 1) + filters := httpx.Filters(c.r) + pos, err := c.s.Cursors.Decode(deref(params.Cursor), filters) + if err != nil { + return err + } + where, args = entFilters(where, args, appFilter, enumStr(params.Status), enumStr(params.Source), params.IncludeInactive) + if params.UserId != nil { + where = append(where, "e.user_id = :uid") + args["uid"] = *params.UserId + } + if params.OrganizationId != nil { + where = append(where, "e.organization_id = :oid") + args["oid"] = *params.OrganizationId + } + if params.OrderId != nil { + where = append(where, "e.order_id = :ord") + args["ord"] = *params.OrderId + } + if params.EndsBefore != nil { + where = append(where, "e.ends_at < :eb::timestamptz") + args["eb"] = db.Time(*params.EndsBefore) + } + if pos != nil { + where = append(where, "(e.created_at, e.id) < (:ps::timestamptz, :pi)") + args["ps"], args["pi"] = pos.Sort, pos.ID + } + if len(where) == 0 { + where = append(where, "true") + } + rows, err := c.queryEnts(strings.Join(where, " and ")+" order by e.created_at desc, e.id desc limit :lim", args) + if err != nil { + return err + } + return c.OK(httpx.Paginate(c.s.Cursors, rows, lim, filters, func(e entitlement) (string, string) { + return httpx.TimeKey(e.CreatedAt), e.ID + })) + }) +} + +// ── PATCH /v1/entitlements/{id} — the toggle ─────────────────────────── + +func (s *Server) EntitlementsUpdate(w http.ResponseWriter, r *http.Request, id string, params gen.EntitlementsUpdateParams) { + s.serve(w, r, opts{op: "entitlements.update", idem: idemOptional}, func(c *Call) error { + e, err := c.loadEnt(id) + if err != nil { + return err + } + ea, err := c.entAccessFor(e) + if err != nil { + return err + } + if !ea.read { + return httpx.NotFoundResource("entitlement") + } + if !ea.manage && !ea.orgAdmin { + return httpx.Forbidden("entitlements.manage") + } + if err := httpx.CheckIfMatch(c.r, e.version); err != nil { + return err + } + p, err := c.Patch() + if err != nil { + return err + } + if err := p.RejectReadOnly("id", "starts_at", "user_id", "organization_id", "application_id", "test_mode", + "created_at", "updated_at", "included_member_count", "granted_via"); err != nil { + return err + } + if !ea.manage { + // An org admin may change only status (active ⇄ disabled) and + // member scope, on their own Organization's org_seat row. + for k := range p { + if k != "status" && k != "disabled_reason" && k != "member_scope" && k != "member_overrides" { + return httpx.Forbidden("entitlements.manage") + } + } + } + var f httpx.Fields + p.RequireNonNull(&f, "status", "source") + next := e + var newStatus string + if p.Get("status", &newStatus, &f) && newStatus != e.Status { + next.Status = newStatus + } + if p.Has("disabled_reason") { + next.DisabledReason, _ = p.String("disabled_reason", &f) + } + if p.Has("ends_at") { + if p.IsNull("ends_at") { + next.EndsAt = nil + } else { + var t time.Time + if p.Get("ends_at", &t, &f) { + t = t.UTC() + next.EndsAt = &t + } + } + } + if p.Has("source") { + var src string + if p.Get("source", &src, &f) && src != e.Source { + if !(e.Source == "trial" && src == "purchase") { + return httpx.E(422, "immutable_field", "source can only change from trial to purchase.").With("field", "source") + } + next.Source = src + } + } + if p.Has("order_id") { + ord, _ := p.String("order_id", &f) + if e.OrderID != nil && (ord == nil || *ord != *e.OrderID) { + return httpx.E(422, "immutable_field", "order_id is set once and then immutable.").With("field", "order_id") + } + if ord != nil && len(*ord) > 255 { + f.Max("order_id", "too_long", 255) + } + next.OrderID = ord + } + scopeChanged := false + if p.Has("member_scope") || p.Has("member_overrides") { + if e.OrganizationID == nil { + return httpx.E(422, "not_an_org_grant", "member_scope and member_overrides apply only to org_seat rows.") + } + if p.Has("member_scope") { + var ms string + if p.Get("member_scope", &ms, &f) { + if ms != "all_members" && ms != "allowlist" && ms != "denylist" { + f.Enum("member_scope", "all_members", "allowlist", "denylist") + } + next.MemberScope = &ms + } + } + if p.Has("member_overrides") { + if p.IsNull("member_overrides") { + next.MemberOverrides = nil + } else { + var mo []string + if p.Get("member_overrides", &mo, &f) { + if len(mo) > 1000 { + f.Max("member_overrides", "too_long", 1000) + } + next.MemberOverrides = dedupe(mo) + } + } + } + if err := f.Err(); err != nil { + return err + } + if err := c.checkOverridesAreMembers(*e.OrganizationID, next.MemberOverrides); err != nil { + return err + } + scopeChanged = deref(next.MemberScope) != deref(e.MemberScope) || !slices.Equal(next.MemberOverrides, e.MemberOverrides) + } + if err := f.Err(); err != nil { + return err + } + statusChanged := next.Status != e.Status + if statusChanged { + if err := checkEntTransition(e, &next, ea.manage, p.Has("ends_at"), c.now); err != nil { + return err + } + if err := c.destructiveIf(next.Status == "disabled" || next.Status == "revoked"); err != nil { + return err + } + } else if e.Status == "revoked" && len(p) > 0 { + return httpx.Conflict("invalid_status_transition", "A revoked Entitlement is terminal; grant a new one instead.") + } + // ends_at must follow starts_at; a past value is allowed and simply + // expires the row on the next sweep. + if p.Has("ends_at") && next.EndsAt != nil && !next.EndsAt.After(e.StartsAt) { + f.Add("ends_at", "out_of_range").Message = "must be after starts_at" + return f.Err() + } + if next.Source == "trial" && next.EndsAt == nil { + return httpx.E(422, "ends_at_required", "A trial needs an ends_at.") + } + a, err := c.loadApp(e.ApplicationID) + if err != nil { + return err + } + t, err := c.loadTenant(a.TenantID) + if err != nil { + return err + } + if err := tenantWritable(t); err != nil { + return err + } + if statusChanged && next.Status == "active" { + if e.UserID != nil { + if err := c.checkNewSeat(t, *e.UserID); err != nil { + return err + } + if e.Status == "expired" { + live, err := c.queryEnts(`e.user_id = :u and e.application_id = :a and e.status in ('active','disabled') and e.id <> :id`, + db.Args{"u": *e.UserID, "a": e.ApplicationID, "id": e.ID}) + if err != nil { + return err + } + if len(live) > 0 { + return httpx.Conflict("entitlement_already_exists", "A newer live Entitlement exists; change that one instead."). + With("existing_entitlement_id", live[0].ID) + } + } + } else if t.Restricted && !c.testMode() { + return subscriptionRequired(t) + } + } + pairs, err := c.affectedPairs(e) + if err != nil { + return err + } + watch, err := c.watchAccess(pairs) + if err != nil { + return err + } + if next.Status == "active" { + next.DisabledReason = nil // cleared automatically on re-enable + } + if _, err := c.q.Exec(c.ctx, `update entitlements set status = :st, disabled_reason = :dr, ends_at = :en::timestamptz, + source = :src, order_id = :ord, member_scope = :ms, member_overrides = :mo::text[] + where id = :id`, + db.Args{"st": next.Status, "dr": next.DisabledReason, "en": db.NullTime(next.EndsAt), "src": next.Source, + "ord": next.OrderID, "ms": next.MemberScope, "mo": db.NullTextArray(next.MemberOverrides), "id": e.ID}); err != nil { + if _, ok := db.UniqueViolation(err); ok { + return httpx.Conflict("entitlement_already_exists", "A live Entitlement already exists for this holder and Application.") + } + return err + } + after, err := c.loadEnt(e.ID) + if err != nil { + return err + } + changed := changedFields(e, after) + switch { + case statusChanged: + action := map[string]string{"active": "entitlement.granted", "disabled": "entitlement.disabled", + "revoked": "entitlement.revoked"}[next.Status] + if err := c.entitlementEffects(action, &e, after, nil); err != nil { + return err + } + case len(changed) > 0: + if err := c.entitlementEffects("entitlement.updated", &e, after, map[string]any{"changed_fields": changed}); err != nil { + return err + } + } + if scopeChanged { + if err := c.entitlementEffects("entitlement.member_scope_changed", &e, after, nil); err != nil { + return err + } + } + granted, revoked := entReasons(e, next.Status) + if e.OrganizationID != nil { + granted, revoked = "org_grant_changed", "org_grant_changed" + } + if err := c.flip(watch, granted, revoked); err != nil { + return err + } + c.Versioned(after.version) + return c.OK(after) + }) +} + +// entReasons are the access.* reasons for a personal row's change. +func entReasons(before entitlement, to string) (granted, revoked string) { + switch to { + case "revoked": + return "entitlement_enabled", "entitlement_revoked" + case "disabled": + return "entitlement_enabled", "entitlement_disabled" + case before.Status: + // A term change: ends_at moved into the past expires access, or + // moved forward restores it. + return "entitlement_enabled", "entitlement_expired" + default: + return "entitlement_enabled", "entitlement_disabled" + } +} + +// checkEntTransition applies the Entitlements status transition table. +func checkEntTransition(e entitlement, next *entitlement, manage, endsSent bool, now time.Time) error { + bad := func() error { + return httpx.Conflict("invalid_status_transition", "The status change from "+e.Status+" to "+next.Status+" isn't allowed.") + } + reasonRequired := func() error { + if next.DisabledReason == nil || *next.DisabledReason == "" { + return httpx.E(422, "disabled_reason_required", "disabled_reason is required for this transition.") + } + if len([]rune(*next.DisabledReason)) > 255 { + var f httpx.Fields + f.Max("disabled_reason", "too_long", 255) + return f.Err() + } + return nil + } + switch { + case e.Status == "revoked": + return bad() + case e.Status == "active" && next.Status == "disabled": + return reasonRequired() + case e.Status == "disabled" && next.Status == "active": + return nil + case next.Status == "revoked" && (e.Status == "active" || e.Status == "disabled" || e.Status == "expired"): + if !manage { + return httpx.Forbidden("entitlements.manage") + } + return reasonRequired() + case e.Status == "expired" && next.Status == "active": + if !manage { + return httpx.Forbidden("entitlements.manage") + } + if !endsSent || (next.EndsAt != nil && !next.EndsAt.After(now)) { + return httpx.Conflict("invalid_status_transition", "Renewing an expired Entitlement needs a future ends_at, or ends_at: null.") + } + return nil + } + return bad() +} + +func (c *Call) checkOverridesAreMembers(orgID string, overrides []string) error { + if len(overrides) == 0 { + return nil + } + members, err := db.All[string](c.ctx, c.q, `select to_jsonb(user_id) from organization_memberships + where organization_id = :o and status = 'active' and user_id = any(:ids::text[])`, + db.Args{"o": orgID, "ids": db.TextArray(overrides)}) + if err != nil { + return err + } + for _, id := range overrides { + if !slices.Contains(members, id) { + return httpx.E(422, "member_override_not_a_member", "Every member_overrides id must be a current active member."). + With("user_id", id) + } + } + return nil +} + +func changedFields(a, b entitlement) []string { + var out []string + if !timePtrEq(a.EndsAt, b.EndsAt) { + out = append(out, "ends_at") + } + if a.Source != b.Source { + out = append(out, "source") + } + if deref(a.OrderID) != deref(b.OrderID) { + out = append(out, "order_id") + } + return out +} + +func timePtrEq(a, b *time.Time) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return a.Equal(*b) +} + +func dedupe(ss []string) []string { + seen := map[string]bool{} + out := []string{} + for _, s := range ss { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + return out +} + +// ── DELETE /v1/entitlements/{id} ─────────────────────────────────────── + +func (s *Server) EntitlementsDelete(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "entitlements.delete"}, func(c *Call) error { + e, err := c.loadEnt(id) + if err != nil { + return err + } + ea, err := c.entAccessFor(e) + if err != nil { + return err + } + if !ea.read { + return httpx.NotFoundResource("entitlement") + } + if !ea.manage { + return httpx.Forbidden("entitlements.manage") + } + if err := httpx.CheckIfMatch(c.r, e.version); err != nil { + return err + } + if e.OrderID != nil { + return httpx.Conflict("entitlement_order_linked", "An order-linked Entitlement is a financial record; revoke it instead.") + } + if c.now.Sub(e.CreatedAt) > 24*time.Hour { + return httpx.Conflict("entitlement_too_old", "This Entitlement is over 24 hours old; revoke it instead.") + } + pairs, err := c.affectedPairs(e) + if err != nil { + return err + } + watch, err := c.watchAccess(pairs) + if err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `delete from entitlements where id = :id`, db.Args{"id": e.ID}); err != nil { + return err + } + app := e.ApplicationID + if err := c.audit(Audit{Action: "entitlement.deleted", TargetType: "entitlement", TargetID: e.ID, + TargetUserID: e.UserID, ApplicationID: &app, OrganizationID: e.OrganizationID, TenantID: strp(e.tenantID), + Before: e.snapshot()}); err != nil { + return err + } + if err := c.emit("entitlement.deleted", &app, map[string]any{"entitlement": e.snapshot()}); err != nil { + return err + } + reason := "entitlement_deleted" + if e.OrganizationID != nil { + reason = "org_grant_changed" + } + if err := c.flip(watch, "entitlement_granted", reason); err != nil { + return err + } + return c.NoContent() + }) +} diff --git a/internal/core/entitlements_test.go b/internal/core/entitlements_test.go new file mode 100644 index 0000000..799e23a --- /dev/null +++ b/internal/core/entitlements_test.go @@ -0,0 +1,201 @@ +package core_test + +import ( + "testing" + "time" + + "github.com/Adron/substratalapps.com/internal/testenv" +) + +const system = "usr_01JAG0SYSTEM00000000000000" + +func TestEntitlementLifecycle(t *testing.T) { + e := testenv.New(t) + u := e.Signup() + app := e.App(system, nil) + path := "/v1/users/" + u.ID + "/entitlements" + + e.Post(path, e.Admin, map[string]any{"application_id": app, "source": "admin_grant"}).ExpectErr(400, "idempotency_key_required") + e.PostIdem(path, e.Admin, testenv.Unique(), map[string]any{"application_id": app, "source": "org_seat"}).ExpectErr(422, "invalid_source") + e.PostIdem(path, e.Admin, testenv.Unique(), map[string]any{"application_id": app, "source": "trial"}).ExpectErr(422, "ends_at_required") + + // Idempotency: same key + body replays; same key + different body conflicts. + key := testenv.Unique() + body := map[string]any{"application_id": app, "source": "purchase", "order_id": "sub_" + testenv.Unique()} + first := e.PostIdem(path, e.Admin, key, body).Expect(201) + again := e.PostIdem(path, e.Admin, key, body).Expect(201) + if again.Str("id") != first.Str("id") || again.Header.Get("Idempotent-Replayed") != "true" { + t.Fatalf("replay = %s (%v)", again.Body, again.Header) + } + e.PostIdem(path, e.Admin, key, map[string]any{"application_id": app, "source": "admin_grant"}).ExpectErr(409, "idempotency_key_reused") + // One live personal row per (user, app). + dup := e.PostIdem(path, e.Admin, testenv.Unique(), map[string]any{"application_id": app, "source": "admin_grant"}). + ExpectErr(409, "entitlement_already_exists") + if dup.JSON()["error"].(map[string]any)["details"].(map[string]any)["existing_entitlement_id"] != first.Str("id") { + t.Fatalf("details = %s", dup.Body) + } + + id := first.Str("id") + ep := "/v1/entitlements/" + id + // ETag / If-Match. + g := e.Get(ep, e.Admin).Expect(200) + etag := g.Header.Get("ETag") + e.Do(testenv.Req{Method: "PATCH", Path: ep, Token: e.Admin, Body: map[string]any{"status": "disabled"}, + Header: map[string]string{"If-Match": etag}}).ExpectErr(422, "disabled_reason_required") + e.Do(testenv.Req{Method: "PATCH", Path: ep, Token: e.Admin, Body: map[string]any{"status": "disabled", "disabled_reason": "billing_dispute"}, + Header: map[string]string{"If-Match": `W/"999"`}}).ExpectErr(409, "version_conflict") + off := e.Do(testenv.Req{Method: "PATCH", Path: ep, Token: e.Admin, Body: map[string]any{"status": "disabled", "disabled_reason": "billing_dispute"}, + Header: map[string]string{"If-Match": etag}}).Expect(200) + if off.Str("disabled_reason") != "billing_dispute" { + t.Fatalf("disabled = %s", off.Body) + } + on := e.Patch(ep, e.Admin, map[string]any{"status": "active"}).Expect(200) + if on.JSON()["disabled_reason"] != nil { + t.Fatal("re-enable should clear disabled_reason") + } + // Immutable fields and read-only fields. + e.Patch(ep, e.Admin, map[string]any{"order_id": "sub_other"}).ExpectErr(422, "immutable_field") + e.Patch(ep, e.Admin, map[string]any{"source": "trial"}).ExpectErr(422, "immutable_field") + e.Patch(ep, e.Admin, map[string]any{"user_id": "usr_x"}).ExpectErr(422, "read_only_field") + // Term change is entitlement.updated. + e.Patch(ep, e.Admin, map[string]any{"ends_at": time.Now().Add(240 * time.Hour).UTC().Format(time.RFC3339)}).Expect(200) + if len(e.Events("entitlement.updated")) == 0 { + t.Fatal("expected entitlement.updated") + } + // Order-linked rows can't be hard-deleted. + e.Delete(ep, e.Admin).ExpectErr(409, "entitlement_order_linked") + // Revoked is terminal. + e.Patch(ep, e.Admin, map[string]any{"status": "revoked", "disabled_reason": "refunded"}).Expect(200) + e.Patch(ep, e.Admin, map[string]any{"status": "active"}).ExpectErr(409, "invalid_status_transition") + // A revoked row doesn't block a new grant; default lists hide it. + e.Grant(e.Admin, u.ID, app) + list := e.Get(path, e.Admin).Expect(200).JSON()["data"].([]any) + if len(list) != 1 { + t.Fatalf("default list = %d rows", len(list)) + } + all := e.Get(path+"?include_inactive=true", e.Admin).Expect(200).JSON()["data"].([]any) + if len(all) != 2 { + t.Fatalf("include_inactive list = %d rows", len(all)) + } +} + +func TestEntitlementDeleteAndScheduled(t *testing.T) { + e := testenv.New(t) + u := e.Signup() + app := e.App(system, nil) + id := e.Grant(e.Admin, u.ID, app) + e.Delete("/v1/entitlements/"+id, e.Admin).Expect(204) + e.Get("/v1/entitlements/"+id, e.Admin).ExpectErr(404, "entitlement_not_found") + if len(e.Events("entitlement.deleted")) == 0 { + t.Fatal("expected entitlement.deleted") + } + // A future starts_at resolves as scheduled. + future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339) + e.PostIdem("/v1/users/"+u.ID+"/entitlements", e.Admin, testenv.Unique(), + map[string]any{"application_id": app, "source": "admin_grant", "starts_at": future}).Expect(201) + eff := e.Get("/v1/users/"+u.ID+"/apps/"+app+"/effective-permissions", e.Admin).Expect(200).JSON() + if eff["entitlement_status"] != "scheduled" || eff["allowed"] != false { + t.Fatalf("scheduled = %v", eff) + } + e.Post("/v1/auth/app-tokens", u.Token, map[string]any{"application_id": app}).ExpectErr(403, "entitlement_required") +} + +func TestAppConfinedKey(t *testing.T) { + e := testenv.New(t) + app := e.App(system, nil) + other := e.App(system, nil) + key := e.Post("/v1/api-keys", e.Admin, map[string]any{"name": "backend", "scope": app, + "permissions": []string{"entitlements.manage", "users.list"}}).Expect(201).Str("secret") + e.Post("/v1/api-keys", e.Admin, map[string]any{"name": "bad", "scope": app, "permissions": []string{"users.manage"}}). + ExpectErr(422, "permission_not_grantable_to_scope") + u := e.Signup() + e.Grant(key, u.ID, app) + // Another Application's rows don't exist for this key. + e.PostIdem("/v1/users/"+u.ID+"/entitlements", key, testenv.Unique(), map[string]any{"application_id": other, "source": "admin_grant"}). + ExpectErr(404, "application_not_found") + otherEnt := e.Grant(e.Admin, u.ID, other) + e.Get("/v1/entitlements/"+otherEnt, key).ExpectErr(404, "entitlement_not_found") + // users.list is forced to its own Application. + users := e.Get("/v1/users", key).Expect(200).JSON()["data"].([]any) + if len(users) != 1 || users[0].(map[string]any)["id"] != u.ID { + t.Fatalf("app-confined users = %v", users) + } + // A key can't manage API Keys. + e.Post("/v1/api-keys", key, map[string]any{"name": "x", "scope": app, "permissions": []string{}}).ExpectErr(400, "user_token_required") + // me needs a user token. + e.Get("/v1/users/me", key).ExpectErr(400, "user_token_required") +} + +func TestRestrictDestructive(t *testing.T) { + e := testenv.New(t) + app := e.App(system, nil) + agent := e.Post("/v1/api-keys", e.Admin, map[string]any{"name": "agent", "scope": "platform", "intended_use": "agent", + "permissions": []string{"entitlements.manage", "users.list"}}).Expect(201) + if agent.JSON()["restrict_destructive"] != true { + t.Fatal("agent keys default to restrict_destructive") + } + key := agent.Str("secret") + u := e.Signup() + id := e.Grant(key, u.ID, app) // non-destructive writes are fine + e.Patch("/v1/entitlements/"+id, key, map[string]any{"status": "disabled", "disabled_reason": "x"}). + ExpectErr(403, "destructive_operation_restricted") + e.Patch("/v1/entitlements/"+id, key, map[string]any{"ends_at": time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339)}).Expect(200) + e.Delete("/v1/entitlements/"+id, key).ExpectErr(403, "destructive_operation_restricted") + // Turning the safety off on an agent key is audited. + e.Patch("/v1/api-keys/"+agent.Str("id"), e.Admin, map[string]any{"restrict_destructive": false}).Expect(200) + audit := e.Get("/v1/audit-events?action=api_key.restrict_destructive_disabled&target_id="+agent.Str("id"), e.Admin).Expect(200) + if len(audit.JSON()["data"].([]any)) != 1 { + t.Fatalf("audit = %s", audit.Body) + } + e.Patch("/v1/entitlements/"+id, key, map[string]any{"status": "disabled", "disabled_reason": "x"}).Expect(200) +} + +func TestApplications(t *testing.T) { + e := testenv.New(t) + slug := "inv-" + testenv.Unique() + bad := e.Post("/v1/applications", e.Admin, map[string]any{"slug": "Bad_Slug", "name": "x", "launch_url": "http://x.example.com", + "owner_user_id": system, "available_app_roles": make([]string, 21)}).ExpectErr(422, "validation_failed") + if n := len(bad.JSON()["error"].(map[string]any)["details"].(map[string]any)["fields"].([]any)); n < 3 { + t.Fatalf("expected every field error at once, got %d: %s", n, bad.Body) + } + e.Post("/v1/applications", e.Admin, map[string]any{"slug": "platform", "name": "x", "launch_url": "https://x.example.com", + "owner_user_id": system}).ExpectErr(422, "validation_failed") + e.Post("/v1/applications", e.Admin, map[string]any{"slug": slug, "name": "x", "launch_url": "https://x.example.com", + "owner_user_id": system, "settings_schema": map[string]any{"type": "object", "properties": map[string]any{"theme": map[string]any{"type": "string"}}}}). + ExpectErr(422, "reserved_settings_key") + owner := e.Signup() + created := e.Post("/v1/applications", e.Admin, map[string]any{"slug": slug, "name": "Invoicer", "launch_url": "https://inv.example.com", + "owner_user_id": owner.ID, "available_app_roles": []string{"admin", "member"}, + "permissions": []map[string]any{{"key": "app." + slug + ".view"}}}).Expect(201) + app := created.Str("id") + if app != "app_"+slug { + t.Fatalf("id = %s", app) + } + e.Post("/v1/applications", e.Admin, map[string]any{"slug": slug, "name": "dup", "launch_url": "https://x.example.com", + "owner_user_id": system}).ExpectErr(409, "slug_taken") + e.Get("/v1/roles/role_"+slug+"_admin", e.Admin).Expect(200) + // The owner's Starter Tenant caps Applications at 1 and AppRoles at 3. + e.Post("/v1/applications", e.Admin, map[string]any{"slug": "second-" + testenv.Unique(), "name": "x", + "launch_url": "https://x.example.com", "owner_user_id": owner.ID}).ExpectErr(409, "plan_limit_reached") + e.Patch("/v1/applications/"+app, owner.Token, map[string]any{"available_app_roles": []string{"a1", "a2", "a3", "a4"}}). + ExpectErr(409, "plan_limit_reached") + // Owner may configure, not moderate. + e.Patch("/v1/applications/"+app, owner.Token, map[string]any{"name": "Invoicer 2"}).Expect(200) + e.Patch("/v1/applications/"+app, owner.Token, map[string]any{"visibility": "internal"}).ExpectErr(403, "moderation_field_forbidden") + e.Patch("/v1/applications/"+app, owner.Token, map[string]any{"slug": "new"}).ExpectErr(422, "read_only_field") + // Removing an in-use role name or permission is refused. + u := e.Signup() + e.Post("/v1/users/"+u.ID+"/roles/role_"+slug+"_admin", owner.Token, map[string]any{}).Expect(201) + e.Patch("/v1/applications/"+app, owner.Token, map[string]any{"available_app_roles": []string{"member"}}).ExpectErr(409, "app_role_in_use") + e.Patch("/v1/roles/role_"+slug+"_member", owner.Token, map[string]any{"permissions": []string{"app." + slug + ".view"}}).Expect(200) + e.Patch("/v1/applications/"+app, owner.Token, map[string]any{"permissions": []any{}}).ExpectErr(409, "permission_in_use") + // Review lifecycle. + e.Patch("/v1/applications/"+app, e.Admin, map[string]any{"review_status": "suspended"}).ExpectErr(422, "review_notes_required") + e.Patch("/v1/applications/"+app, e.Admin, map[string]any{"review_status": "rejected", "review_notes": "x"}).ExpectErr(409, "invalid_review_transition") + e.Patch("/v1/applications/"+app, e.Admin, map[string]any{"review_status": "suspended", "review_notes": "abuse"}).Expect(200) + // Suspension blocks new grants; existing ones are untouched. + e.PostIdem("/v1/users/"+u.ID+"/entitlements", e.Admin, testenv.Unique(), + map[string]any{"application_id": app, "source": "admin_grant"}).ExpectErr(409, "application_not_available") + e.Patch("/v1/applications/"+app, e.Admin, map[string]any{"review_status": "approved"}).Expect(200) + e.Grant(e.Admin, u.ID, app) +} diff --git a/internal/core/export.go b/internal/core/export.go new file mode 100644 index 0000000..3674ad2 --- /dev/null +++ b/internal/core/export.go @@ -0,0 +1,193 @@ +package core + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/api/gen" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/schema" +) + +// UsersExport is GDPR Article 20 / CCPA right-to-know: everything keyed to +// the User, reading the same tables the hard-delete cascade clears (plus +// global Settings, which it deliberately keeps). +func (s *Server) UsersExport(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe) { + s.serve(w, r, opts{op: "users.export"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + if !c.isSelf(uid) && !c.can("users.manage") { + return httpx.Forbidden("users.manage") + } + u, err := c.visibleUser(uid) + if err != nil { + return err + } + if err := c.hit("export:"+uid, perDay(5), false); err != nil { + return err + } + out := map[string]any{"exported_at": c.now.Format(time.RFC3339), "user": u} + if p, err := c.loadProfile(uid); err == nil { + out["profile"] = p + } + if st, err := c.loadSettings(uid); err == nil { + out["settings"] = st + } + sections := map[string]string{ + "identities": `select jsonb_build_object('method', method, 'mfa_enabled', mfa_enabled, 'last_used_at', last_used_at) + from user_identities where user_id = :u order by created_at`, + "sessions": `select jsonb_build_object('id', id, 'created_at', created_at, 'last_seen_at', last_seen_at, + 'expires_at', least(idle_expires_at, absolute_expires_at), 'ip_address', host(ip_address), + 'user_agent', user_agent, 'amr', amr) from sessions where user_id = :u order by created_at`, + "organizations": `select jsonb_build_object('organization_id', organization_id, 'role', role, + 'membership_status', status, 'joined_at', joined_at) from organization_memberships where user_id = :u`, + } + for key, q := range sections { + rows, err := c.q.Query(c.ctx, q, db.Args{"u": uid}) + if err != nil { + return err + } + out[key] = rawList(rows) + } + roles, err := c.loadAssignments(uid, nil) + if err != nil { + return err + } + out["roles"] = roles + apps, err := db.All[string](c.ctx, c.q, `select to_jsonb(a) from ( + select application_id as a from entitlements where user_id = :u + union select application_id from app_profiles where user_id = :u + union select application_id from app_settings where user_id = :u) x order by a`, db.Args{"u": uid}) + if err != nil { + return err + } + var appData []map[string]any + for _, appID := range apps { + entry := map[string]any{"application_id": appID} + if es, err := c.queryEnts("e.user_id = :u and e.application_id = :a order by e.created_at desc", + db.Args{"u": uid, "a": appID}); err == nil && len(es) > 0 { + entry["entitlement"] = es[0] + } + if ap, err := c.loadAppProfile(uid, appID); err == nil { + entry["app_profile"] = ap + } + var ov json.RawMessage + if err := db.One(c.ctx, c.q, &ov, `select overrides from app_settings where user_id = :u and application_id = :a`, + db.Args{"u": uid, "a": appID}); err == nil { + entry["app_settings"] = map[string]any{"overrides": ov} + } + appData = append(appData, entry) + } + if appData == nil { + appData = []map[string]any{} + } + out["applications"] = appData + events, err := db.All[auditEvent](c.ctx, c.q, auditSQL+` where a.target_user_id = :u order by a.timestamp desc, a.id desc`, + db.Args{"u": uid}) + if err != nil { + return err + } + out["audit_events"] = events + return c.OK(out) + }) +} + +func rawList(rows []json.RawMessage) []json.RawMessage { + if rows == nil { + return []json.RawMessage{} + } + return rows +} + +// ── Typed settings projection (Database Schema → Typed fields) ───────── + +func hashName(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:])[:12] +} + +// ProjectionDDL returns the statements that (re)build one Application's +// typed settings view and per-property partial expression indexes. Names +// are hashes (identifiers cap at 63 bytes); comments record readable names. +func ProjectionDDL(appID string, rawSchema json.RawMessage, existingIndexes []string) []string { + sch, _, _ := schema.Parse(rawSchema) + a := hashName(appID) + view := "app_settings_" + a + quotedApp := strings.ReplaceAll(appID, "'", "''") + cols := []string{"user_id"} + var stmts []string + want := map[string]bool{} + cast := map[string]string{"boolean": "try_bool", "integer": "try_int", "number": "try_num", "string": "try_text"} + for _, k := range sch.Order { + p := sch.Props[k] + if !p.Scalar() { + continue + } + fn := cast[p.Type] + if p.Type == "string" && p.Format == "date-time" { + fn = "try_timestamptz" + } + expr := fmt.Sprintf("substratal_util.%s(overrides->'%s')", fn, k) + cols = append(cols, fmt.Sprintf("%s as %s", expr, k)) + idx := view + "_" + hashName(k) + want[idx] = true + stmts = append(stmts, + fmt.Sprintf("create index concurrently if not exists %s on app_settings ((%s)) where application_id = '%s'", idx, expr, quotedApp), + fmt.Sprintf("comment on index %s is '%s.%s'", idx, quotedApp, k)) + } + pre := []string{ + fmt.Sprintf("drop view if exists %s", view), + fmt.Sprintf("create view %s as select %s from app_settings where application_id = '%s'", view, strings.Join(cols, ", "), quotedApp), + fmt.Sprintf("comment on view %s is '%s'", view, quotedApp), + } + for _, idx := range existingIndexes { + if !want[idx] { + pre = append(pre, "drop index concurrently if exists "+idx) + } + } + return append(pre, stmts...) +} + +// rebuildSettingsProjection runs after the transaction that changed an +// Application's schema commits, never inside the request itself. Index +// builds run CONCURRENTLY, so they go through autocommit, one statement +// at a time. Failures are logged; the next schema change or the nightly +// job retries. +func (s *Server) rebuildSettingsProjection(ctx context.Context, appID string) { + if err := RebuildSettingsProjection(ctx, s.DB, appID); err != nil { + s.Log.Error("settings projection rebuild failed", "application", appID, "err", err) + } +} + +// RebuildSettingsProjection is rebuildSettingsProjection for jobs. +func RebuildSettingsProjection(ctx context.Context, d db.DB, appID string) error { + var raw json.RawMessage + var existing []string + err := d.Tx(ctx, db.Settings{AllModes: true}, func(q db.Querier) error { + if err := db.One(ctx, q, &raw, `select settings_schema from applications where id = :a`, db.Args{"a": appID}); err != nil { + return err + } + var err error + existing, err = db.All[string](ctx, q, `select to_jsonb(indexname) from pg_indexes + where tablename = 'app_settings' and indexname like :p`, db.Args{"p": "app_settings_" + hashName(appID) + "_%"}) + return err + }) + if err != nil { + return err + } + for _, st := range ProjectionDDL(appID, raw, existing) { + if _, err := d.Autocommit().Exec(ctx, st, nil); err != nil { + return fmt.Errorf("%s: %w", st, err) + } + } + return nil +} diff --git a/internal/core/mail.go b/internal/core/mail.go new file mode 100644 index 0000000..6f73bcd --- /dev/null +++ b/internal/core/mail.go @@ -0,0 +1,84 @@ +package core + +import ( + "context" + "fmt" + "net/url" + "time" + + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/email" + "github.com/Adron/substratalapps.com/internal/ids" +) + +// mail queues a transactional email to send after the transaction commits. +// A send failure is logged, never surfaced: the change already happened, +// and every email here can be re-requested (resend, forgot, invitation). +func (c *Call) mail(m email.Message) { + c.After(func(ctx context.Context) { + if err := c.s.Email.Send(ctx, m); err != nil { + c.s.Log.Error("email send failed", "kind", m.Kind, "request_id", c.requestID, "err", err) + } + }) +} + +// link builds a dashboard link carrying a single-use token. +func (c *Call) link(path, token string) string { + return c.s.DashboardURL + path + "?token=" + url.QueryEscape(token) +} + +// appBranding returns an Application's email_from_name for messages +// triggered from inside it (Auth → Email verification). +func (c *Call) appBranding(appID *string) string { + if appID == nil { + return "" + } + a, err := c.loadApp(*appID) + if err != nil || a.EmailFromName == nil { + return "" + } + return *a.EmailFromName +} + +// issueToken stores a single-use emailed/challenge token (hash only) and +// invalidates the user's earlier unconsumed tokens of the same kind. +func (c *Call) issueToken(kind, prefix, userID string, ttl time.Duration, payload map[string]any) (string, error) { + if _, err := c.q.Exec(c.ctx, `update auth_tokens set consumed_at = now() + where user_id = :u and kind = :k and consumed_at is null`, db.Args{"u": userID, "k": kind}); err != nil { + return "", err + } + tok := ids.Secret(prefix) + if payload == nil { + payload = map[string]any{} + } + _, err := c.q.Exec(c.ctx, `insert into auth_tokens (token_hash, kind, user_id, payload, expires_at) + values (:h, :k, :u, :p::jsonb, :exp::timestamptz)`, + db.Args{"h": ids.Hash(tok), "k": kind, "u": userID, "p": db.JSON(payload), "exp": db.Time(c.now.Add(ttl))}) + return tok, err +} + +func (c *Call) sendVerification(userID, to string, appID *string, payload map[string]any) error { + tok, err := c.issueToken("email_verify", ids.EmailVerify, userID, 24*time.Hour, payload) + if err != nil { + return err + } + c.mail(email.Message{To: to, FromName: c.appBranding(appID), Kind: "email_verify", + Subject: "Verify your email address", + Text: fmt.Sprintf("Confirm this email address for your Substratal account:\n\n%s\n\nThis link expires in 24 hours.", c.link("/verify-email", tok))}) + return nil +} + +func (c *Call) sendInvitation(userID, to string, appID *string) error { + tok, err := c.issueToken("invitation", ids.Invitation, userID, 7*24*time.Hour, nil) + if err != nil { + return err + } + c.mail(email.Message{To: to, FromName: c.appBranding(appID), Kind: "invitation", + Subject: "You've been invited to Substratal", + Text: fmt.Sprintf("Set your password and activate your account:\n\n%s\n\nThis invitation expires in 7 days.", c.link("/accept-invitation", tok))}) + return nil +} + +func (c *Call) notice(to, kind, subject, text string) { + c.mail(email.Message{To: to, Kind: kind, Subject: subject, Text: text}) +} diff --git a/internal/core/organizations.go b/internal/core/organizations.go new file mode 100644 index 0000000..5eb6ea0 --- /dev/null +++ b/internal/core/organizations.go @@ -0,0 +1,917 @@ +package core + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/api/gen" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/email" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/ids" +) + +type orgRow struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + CreatedBy string `json:"created_by"` + TestMode bool `json:"test_mode"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` +} + +// organization is the API Organization object. Pending rows in a member's +// own list show only id, name, my_role, and my_membership_status. +type organization struct { + ID string `json:"id"` + Name string `json:"name"` + Status *string `json:"status,omitempty"` + MemberCount *int `json:"member_count,omitempty"` + MyRole *string `json:"my_role,omitempty"` + MyMembershipStatus *string `json:"my_membership_status,omitempty"` + CreatedBy *string `json:"created_by,omitempty"` + TestMode *bool `json:"test_mode,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +func (c *Call) loadOrg(id string) (orgRow, error) { + var o orgRow + err := db.One(c.ctx, c.q, &o, `select to_jsonb(o) from organizations o where id = :id`, db.Args{"id": id}) + if err == db.ErrNotFound { + return o, httpx.NotFoundResource("organization") + } + return o, err +} + +func (c *Call) memberCount(orgID string) (int, error) { + var n int + err := db.One(c.ctx, c.q, &n, `select to_jsonb(count(*)) from organization_memberships + where organization_id = :o and status = 'active'`, db.Args{"o": orgID}) + return n, err +} + +func (c *Call) orgObject(o orgRow, myRole, myStatus *string) (organization, error) { + n, err := c.memberCount(o.ID) + if err != nil { + return organization{}, err + } + st, tm, cb, ca, ua := o.Status, o.TestMode, o.CreatedBy, o.CreatedAt, o.UpdatedAt + return organization{ID: o.ID, Name: o.Name, Status: &st, MemberCount: &n, MyRole: myRole, MyMembershipStatus: myStatus, + CreatedBy: &cb, TestMode: &tm, CreatedAt: &ca, UpdatedAt: &ua}, nil +} + +type membership struct { + UserID string `json:"user_id"` + Role string `json:"role"` + Status string `json:"status"` +} + +func (c *Call) myMembership(orgID string) (*membership, error) { + if c.p == nil || !c.p.IsUser() { + return nil, nil + } + var m membership + err := db.One(c.ctx, c.q, &m, `select jsonb_build_object('user_id', user_id, 'role', role, 'status', status) + from organization_memberships where organization_id = :o and user_id = :u`, db.Args{"o": orgID, "u": c.p.UserID}) + if err == db.ErrNotFound { + return nil, nil + } + return &m, err +} + +// orgReadable: active members and organizations.manage; anyone else +// (including a pending invitee) gets 404. +func (c *Call) orgReadable(orgID string) (orgRow, *membership, error) { + o, err := c.loadOrg(orgID) + if err != nil { + return o, nil, err + } + m, err := c.myMembership(orgID) + if err != nil { + return o, nil, err + } + if c.can("organizations.manage") || (m != nil && m.Status == "active") { + return o, m, nil + } + return o, m, httpx.NotFoundResource("organization") +} + +// orgAdminWrite authorizes an org admin action: organizations.manage, or an +// active org_admin of a non-suspended Organization. +func (c *Call) orgAdminWrite(o orgRow, m *membership) error { + if c.can("organizations.manage") { + return nil + } + if m == nil || m.Status != "active" || m.Role != "org_admin" { + return httpx.Forbidden("organizations.manage") + } + if o.Status == "suspended" { + return httpx.E(403, "organization_suspended", "This Organization is suspended.") + } + return nil +} + +func (s *Server) OrganizationsList(w http.ResponseWriter, r *http.Request, params gen.OrganizationsListParams) { + s.serve(w, r, opts{op: "organizations.list"}, func(c *Call) error { + lim := httpx.Limit(params.Limit) + filters := httpx.Filters(c.r) + pos, err := c.s.Cursors.Decode(deref(params.Cursor), filters) + if err != nil { + return err + } + args := db.Args{"lim": int64(lim + 1)} + var where []string + admin := c.can("organizations.manage") + if !admin { + if !c.p.IsUser() { + return c.OK(httpx.List[organization]{Data: []organization{}}) + } + where = append(where, "o.id in (select organization_id from organization_memberships where user_id = :me)") + args["me"] = c.p.UserID + } else { + if params.Status != nil { + where = append(where, "o.status = :st") + args["st"] = string(*params.Status) + } + if params.Q != nil { + where = append(where, "lower(o.name) like :q") + args["q"] = strings.ToLower(escapeLike(*params.Q)) + "%" + } + } + if pos != nil { + where = append(where, "(o.created_at, o.id) < (:ps::timestamptz, :pi)") + args["ps"], args["pi"] = pos.Sort, pos.ID + } + if len(where) == 0 { + where = []string{"true"} + } + rows, err := db.All[orgRow](c.ctx, c.q, `select to_jsonb(o) from organizations o where `+ + strings.Join(where, " and ")+` order by o.created_at desc, o.id desc limit :lim`, args) + if err != nil { + return err + } + out := make([]organization, 0, len(rows)) + for _, o := range rows { + if admin { + obj, err := c.orgObject(o, nil, nil) + if err != nil { + return err + } + out = append(out, obj) + continue + } + m, err := c.myMembership(o.ID) + if err != nil { + return err + } + if m == nil { + continue + } + if m.Status == "pending" { + out = append(out, organization{ID: o.ID, Name: o.Name, MyRole: &m.Role, MyMembershipStatus: &m.Status}) + continue + } + obj, err := c.orgObject(o, &m.Role, &m.Status) + if err != nil { + return err + } + out = append(out, obj) + } + page := httpx.Paginate(c.s.Cursors, out, lim, filters, func(x organization) (string, string) { return "", "" }) + if page.Page.HasMore { + last := rows[lim-1] + cur := c.s.Cursors.Encode(httpx.TimeKey(last.CreatedAt), last.ID, filters) + page.Page.NextCursor = &cur + } + return c.OK(page) + }) +} + +func (s *Server) OrganizationsCreate(w http.ResponseWriter, r *http.Request, params gen.OrganizationsCreateParams) { + s.serve(w, r, opts{op: "organizations.create", userOnly: true, idem: idemOptional}, func(c *Call) error { + var in struct { + Name string `json:"name"` + } + if err := c.Decode(&in); err != nil { + return err + } + n := len([]rune(strings.TrimSpace(in.Name))) + if n < 1 || n > 100 { + var f httpx.Fields + if n < 1 { + f.Add("name", "required") + } else { + f.Max("name", "too_long", 100) + } + return f.Err() + } + if err := c.hit("orgcreate:"+c.p.UserID, perDay(10), false); err != nil { + return err + } + id := ids.New(ids.Organization) + if _, err := c.q.Exec(c.ctx, `insert into organizations (id, name, created_by, test_mode) values (:id, :n, :u, :tm)`, + db.Args{"id": id, "n": strings.TrimSpace(in.Name), "u": c.p.UserID, "tm": c.testMode()}); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `insert into organization_memberships (user_id, organization_id, role, status, joined_at, test_mode) + values (:u, :o, 'org_admin', 'active', now(), :tm)`, db.Args{"u": c.p.UserID, "o": id, "tm": c.testMode()}); err != nil { + return err + } + o, err := c.loadOrg(id) + if err != nil { + return err + } + obj, err := c.orgObject(o, nil, nil) + if err != nil { + return err + } + if err := c.audit(Audit{Action: "organization.created", TargetType: "organization", TargetID: id, + OrganizationID: &id, After: obj}); err != nil { + return err + } + c.Versioned(o.Version) + return c.Created(obj) + }) +} + +func (s *Server) OrganizationsGet(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "organizations.get"}, func(c *Call) error { + o, m, err := c.orgReadable(id) + if err != nil { + return err + } + var role, status *string + if m != nil { + role, status = &m.Role, &m.Status + } + obj, err := c.orgObject(o, role, status) + if err != nil { + return err + } + c.Versioned(o.Version) + return c.OK(obj) + }) +} + +func (s *Server) OrganizationsUpdate(w http.ResponseWriter, r *http.Request, id string, params gen.OrganizationsUpdateParams) { + s.serve(w, r, opts{op: "organizations.update"}, func(c *Call) error { + o, m, err := c.orgReadable(id) + if err != nil { + return err + } + if err := httpx.CheckIfMatch(c.r, o.Version); err != nil { + return err + } + p, err := c.Patch() + if err != nil { + return err + } + if err := p.RejectReadOnly("id", "member_count", "created_by", "test_mode", "created_at", "updated_at"); err != nil { + return err + } + var f httpx.Fields + p.RequireNonNull(&f, "name", "status") + next := o + if v, present := p.String("name", &f); present && v != nil { + if n := len([]rune(strings.TrimSpace(*v))); n < 1 || n > 100 { + f.Max("name", "too_long", 100) + } + next.Name = strings.TrimSpace(*v) + } + if v, present := p.String("status", &f); present && v != nil { + if !c.can("organizations.manage") { + return httpx.Forbidden("organizations.manage") + } + if *v != "active" && *v != "suspended" { + f.Enum("status", "active", "suspended") + } + next.Status = *v + } + var reason *string + if p.Has("reason") { + reason, _ = p.String("reason", &f) + } + if err := f.Err(); err != nil { + return err + } + if err := c.orgAdminWrite(o, m); err != nil { + return err + } + if err := c.destructiveIf(next.Status == "suspended" && o.Status != "suspended"); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update organizations set name = :n, status = :s where id = :id`, + db.Args{"n": next.Name, "s": next.Status, "id": o.ID}); err != nil { + return err + } + after, err := c.loadOrg(o.ID) + if err != nil { + return err + } + auditAfter := map[string]any{"name": after.Name, "status": after.Status} + if reason != nil { + auditAfter["reason"] = *reason + } + if err := c.audit(Audit{Action: "organization.updated", TargetType: "organization", TargetID: o.ID, + OrganizationID: &o.ID, Before: map[string]any{"name": o.Name, "status": o.Status}, After: auditAfter}); err != nil { + return err + } + var role, status *string + if m != nil { + role, status = &m.Role, &m.Status + } + obj, err := c.orgObject(after, role, status) + if err != nil { + return err + } + c.Versioned(after.Version) + return c.OK(obj) + }) +} + +// ── Members ──────────────────────────────────────────────────────────── + +type member struct { + UserID string `json:"user_id"` + Email string `json:"email"` + DisplayName *string `json:"display_name"` + MembershipStatus string `json:"membership_status"` + Role string `json:"role"` + InvitedAt time.Time `json:"invited_at"` + JoinedAt *time.Time `json:"joined_at"` +} + +// memberSQL deliberately reveals nothing about a pending invitee's +// account: display_name is null until the membership is active. +const memberSQL = `select jsonb_build_object('user_id', m.user_id, 'email', u.email, + 'display_name', case when m.status = 'active' then p.display_name end, + 'membership_status', m.status, 'role', m.role, 'invited_at', m.invited_at, 'joined_at', m.joined_at) + from organization_memberships m join users u on u.id = m.user_id left join profiles p on p.user_id = m.user_id` + +func (c *Call) loadMember(orgID, userID string) (member, error) { + var x member + err := db.One(c.ctx, c.q, &x, memberSQL+` where m.organization_id = :o and m.user_id = :u`, db.Args{"o": orgID, "u": userID}) + if err == db.ErrNotFound { + return x, httpx.NotFoundResource("member") + } + return x, err +} + +func (s *Server) OrganizationsListMembers(w http.ResponseWriter, r *http.Request, id string, params gen.OrganizationsListMembersParams) { + s.serve(w, r, opts{op: "organizations.listMembers"}, func(c *Call) error { + if _, _, err := c.orgReadable(id); err != nil { + return err + } + lim := httpx.Limit(params.Limit) + filters := httpx.Filters(c.r) + pos, err := c.s.Cursors.Decode(deref(params.Cursor), filters) + if err != nil { + return err + } + where := []string{"m.organization_id = :o"} + args := db.Args{"o": id, "lim": int64(lim + 1)} + if params.Role != nil { + where = append(where, "m.role = :role") + args["role"] = string(*params.Role) + } + if params.MembershipStatus != nil { + where = append(where, "m.status = :ms") + args["ms"] = string(*params.MembershipStatus) + } + if params.Q != nil { + where = append(where, "(lower(u.email::text) like :q or (m.status = 'active' and lower(p.display_name) like :q))") + args["q"] = strings.ToLower(escapeLike(*params.Q)) + "%" + } + if pos != nil { + where = append(where, "(m.invited_at, m.user_id) < (:ps::timestamptz, :pi)") + args["ps"], args["pi"] = pos.Sort, pos.ID + } + rows, err := db.All[member](c.ctx, c.q, memberSQL+" where "+strings.Join(where, " and ")+ + " order by m.invited_at desc, m.user_id desc limit :lim", args) + if err != nil { + return err + } + return c.OK(httpx.Paginate(c.s.Cursors, rows, lim, filters, func(x member) (string, string) { + return httpx.TimeKey(x.InvitedAt), x.UserID + })) + }) +} + +func (s *Server) OrganizationsAddMember(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "organizations.addMember", idem: idemOptional}, func(c *Call) error { + o, m, err := c.orgReadable(id) + if err != nil { + return err + } + if err := c.orgAdminWrite(o, m); err != nil { + return err + } + if o.Status == "suspended" { + return httpx.E(403, "organization_suspended", "This Organization is suspended; no new members can join.") + } + var in struct { + UserID *string `json:"user_id"` + Email *string `json:"email"` + Role *string `json:"role"` + } + if err := c.Decode(&in); err != nil { + return err + } + if (in.UserID == nil) == (in.Email == nil) { + return httpx.Invalid("Send exactly one of user_id or email.") + } + role := "member" + if in.Role != nil { + role = *in.Role + if role != "member" && role != "org_admin" { + var f httpx.Fields + f.Enum("role", "org_admin", "member") + return f.Err() + } + } + var uid string + if in.UserID != nil { + if !c.can("organizations.manage") { + return httpx.Forbidden("organizations.manage") + } + u, err := c.visibleUser(*in.UserID) + if err != nil || u.Status == "deleted" { + return httpx.NotFoundResource("user") + } + uid = u.ID + } else { + addr, ok := validEmail(*in.Email) + if !ok { + var f httpx.Fields + f.Add("email", "invalid_format") + return f.Err() + } + u, taken, err := c.emailTaken(addr) + if err != nil { + return err + } + if taken { + uid = u.ID + } else { + // The one way a non-platform caller can cause a User to be + // created: as an invited account for this invitation. + nu, err := c.createUser(newUser{Email: addr, Status: "invited"}) + if err != nil { + return err + } + uid = nu.ID + if err := c.sendInvitation(uid, addr, nil); err != nil { + return err + } + } + if taken && u.Status != "invited" { + c.mail(email.Message{To: u.Email, Kind: "org_invitation", + Subject: fmt.Sprintf("You've been invited to join %s on Substratal", o.Name), + Text: fmt.Sprintf("You've been invited to join %s. Sign in to accept the invitation.", o.Name)}) + } else if taken && u.Status == "invited" { + if err := c.sendInvitation(uid, u.Email, nil); err != nil { + return err + } + } + } + if existing, err := c.loadMember(o.ID, uid); err == nil { + _ = existing + return httpx.Conflict("already_member", "This user is already a member or has a pending invitation.") + } + var invitedBy any + if c.p.IsUser() { + invitedBy = c.p.UserID + } + direct := in.UserID != nil + if _, err := c.q.Exec(c.ctx, `insert into organization_memberships (user_id, organization_id, role, status, invited_by, test_mode) + values (:u, :o, :r, 'pending', :by, :tm)`, + db.Args{"u": uid, "o": o.ID, "r": role, "by": invitedBy, "tm": c.testMode()}); err != nil { + if _, ok := db.UniqueViolation(err); ok { + return httpx.Conflict("already_member", "This user is already a member or has a pending invitation.") + } + return err + } + if err := c.audit(Audit{Action: "organization.member_invited", TargetType: "organization", TargetID: o.ID, + TargetUserID: &uid, OrganizationID: &o.ID, After: map[string]any{"user_id": uid, "role": role}}); err != nil { + return err + } + if direct { + pairs, err := c.orgAppPairs(uid, o.ID) + if err != nil { + return err + } + watch, err := c.watchAccess(pairs) + if err != nil { + return err + } + if err := c.activateMembership(uid, o.ID, ""); err != nil { + return err + } + if err := c.flip(watch, "org_member_added", "org_member_removed"); err != nil { + return err + } + } + x, err := c.loadMember(o.ID, uid) + if err != nil { + return err + } + return c.Created(x) + }) +} + +// activateMembership turns a pending membership active and records it. +func (c *Call) activateMembership(userID, orgID, actorUserID string) error { + var role string + if err := db.One(c.ctx, c.q, &role, `update organization_memberships set status = 'active', joined_at = now() + where user_id = :u and organization_id = :o and status = 'pending' returning to_jsonb(role)`, + db.Args{"u": userID, "o": orgID}); err != nil { + if err == db.ErrNotFound { + return nil + } + return err + } + a := Audit{Action: "organization.member_added", TargetType: "organization", TargetID: orgID, + TargetUserID: &userID, OrganizationID: &orgID, After: map[string]any{"user_id": userID, "role": role}} + if actorUserID != "" { + a.ActorType, a.ActorID = "user", actorUserID + } + if err := c.audit(a); err != nil { + return err + } + return c.emit("organization.member_added", nil, map[string]any{"organization_id": orgID, "user_id": userID, "role": role}) +} + +func (s *Server) OrganizationsAcceptMembership(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "organizations.acceptMembership", userOnly: true}, func(c *Call) error { + o, err := c.loadOrg(id) + if err != nil { + return err + } + m, err := c.myMembership(id) + if err != nil { + return err + } + if m == nil { + return httpx.NotFoundResource("member") + } + if m.Status == "pending" { + if o.Status == "suspended" { + return httpx.E(403, "organization_suspended", "This Organization is suspended.") + } + pairs, err := c.orgAppPairs(c.p.UserID, id) + if err != nil { + return err + } + watch, err := c.watchAccess(pairs) + if err != nil { + return err + } + if err := c.activateMembership(c.p.UserID, id, ""); err != nil { + return err + } + if err := c.flip(watch, "org_member_added", "org_member_removed"); err != nil { + return err + } + } + x, err := c.loadMember(id, c.p.UserID) + if err != nil { + return err + } + return c.OK(x) + }) +} + +func (c *Call) activeAdminCount(orgID string) (int, error) { + var n int + err := db.One(c.ctx, c.q, &n, `select to_jsonb(count(*)) from organization_memberships + where organization_id = :o and role = 'org_admin' and status = 'active'`, db.Args{"o": orgID}) + return n, err +} + +func (s *Server) OrganizationsUpdateMember(w http.ResponseWriter, r *http.Request, id string, userID string) { + s.serve(w, r, opts{op: "organizations.updateMember"}, func(c *Call) error { + o, m, err := c.orgReadable(id) + if err != nil { + return err + } + if err := c.orgAdminWrite(o, m); err != nil { + return err + } + uid, err := c.selfID(userID) + if err != nil { + return err + } + x, err := c.loadMember(id, uid) + if err != nil { + return err + } + var in struct { + Role string `json:"role"` + } + if err := c.Decode(&in); err != nil { + return err + } + if in.Role != "member" && in.Role != "org_admin" { + var f httpx.Fields + f.Enum("role", "org_admin", "member") + return f.Err() + } + if in.Role == x.Role { + return c.OK(x) + } + demotes := x.Role == "org_admin" && in.Role == "member" + if err := c.destructiveIf(demotes); err != nil { + return err + } + if demotes && x.MembershipStatus == "active" { + if n, err := c.activeAdminCount(id); err != nil { + return err + } else if n <= 1 { + return httpx.Conflict("last_org_admin", "An Organization always has at least one active org_admin.") + } + } + if _, err := c.q.Exec(c.ctx, `update organization_memberships set role = :r where organization_id = :o and user_id = :u`, + db.Args{"r": in.Role, "o": id, "u": uid}); err != nil { + return err + } + if err := c.audit(Audit{Action: "organization.member_role_changed", TargetType: "organization", TargetID: id, + TargetUserID: &uid, OrganizationID: &id, Before: map[string]any{"role": x.Role}, + After: map[string]any{"role": in.Role}}); err != nil { + return err + } + after, err := c.loadMember(id, uid) + if err != nil { + return err + } + return c.OK(after) + }) +} + +func (s *Server) OrganizationsRemoveMember(w http.ResponseWriter, r *http.Request, id string, userID string) { + s.serve(w, r, opts{op: "organizations.removeMember"}, func(c *Call) error { + uid, err := c.selfID(userID) + if err != nil { + return err + } + o, err := c.loadOrg(id) + if err != nil { + return err + } + m, err := c.myMembership(id) + if err != nil { + return err + } + if !c.isSelf(uid) { + if !c.can("organizations.manage") && (m == nil || m.Status != "active") { + return httpx.NotFoundResource("organization") + } + if err := c.orgAdminWrite(o, m); err != nil { + return err + } + } + x, err := c.loadMember(id, uid) + if err != nil { + return err + } + if x.Role == "org_admin" && x.MembershipStatus == "active" { + if n, err := c.activeAdminCount(id); err != nil { + return err + } else if n <= 1 { + return httpx.Conflict("last_org_admin", "Promote another org_admin before removing the last one.") + } + } + active := x.MembershipStatus == "active" + var watch *accessWatch + if active { + pairs, err := c.orgAppPairs(uid, id) + if err != nil { + return err + } + if watch, err = c.watchAccess(pairs); err != nil { + return err + } + } + if _, err := c.q.Exec(c.ctx, `delete from organization_memberships where organization_id = :o and user_id = :u`, + db.Args{"o": id, "u": uid}); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update entitlements set member_overrides = array_remove(member_overrides, :u) + where organization_id = :o and :u = any(member_overrides)`, db.Args{"o": id, "u": uid}); err != nil { + return err + } + if err := c.audit(Audit{Action: "organization.member_removed", TargetType: "organization", TargetID: id, + TargetUserID: &uid, OrganizationID: &id, Before: map[string]any{"user_id": uid, "role": x.Role, + "membership_status": x.MembershipStatus}}); err != nil { + return err + } + if active { + if err := c.emit("organization.member_removed", nil, map[string]any{"organization_id": id, "user_id": uid, "role": x.Role}); err != nil { + return err + } + if err := c.flip(watch, "org_member_added", "org_member_removed"); err != nil { + return err + } + } + return c.NoContent() + }) +} + +// ── Org-wide grants ──────────────────────────────────────────────────── + +func (s *Server) OrganizationsListEntitlements(w http.ResponseWriter, r *http.Request, id string, params gen.OrganizationsListEntitlementsParams) { + s.serve(w, r, opts{op: "organizations.listEntitlements"}, func(c *Call) error { + if _, isKey := c.p.AppKey(); !isKey { + if _, _, err := c.orgReadable(id); err != nil { + return err + } + } + lim := httpx.Limit(params.Limit) + filters := httpx.Filters(c.r) + pos, err := c.s.Cursors.Decode(deref(params.Cursor), filters) + if err != nil { + return err + } + where := []string{"e.organization_id = :o"} + args := db.Args{"o": id, "lim": int64(lim + 1)} + app := params.ApplicationId + if k, isKey := c.p.AppKey(); isKey { + app = &k + } + where, args = entFilters(where, args, app, enumStr(params.Status), nil, params.IncludeInactive) + if params.OrderId != nil { + where = append(where, "e.order_id = :ord") + args["ord"] = *params.OrderId + } + if params.EndsBefore != nil { + where = append(where, "e.ends_at < :eb::timestamptz") + args["eb"] = db.Time(*params.EndsBefore) + } + if pos != nil { + where = append(where, "(e.created_at, e.id) < (:ps::timestamptz, :pi)") + args["ps"], args["pi"] = pos.Sort, pos.ID + } + rows, err := c.queryEnts(strings.Join(where, " and ")+" order by e.created_at desc, e.id desc limit :lim", args) + if err != nil { + return err + } + return c.OK(httpx.Paginate(c.s.Cursors, rows, lim, filters, func(e entitlement) (string, string) { + return httpx.TimeKey(e.CreatedAt), e.ID + })) + }) +} + +func (s *Server) OrganizationsGrantEntitlement(w http.ResponseWriter, r *http.Request, id string, params gen.OrganizationsGrantEntitlementParams) { + s.serve(w, r, opts{op: "organizations.grantEntitlement", idem: idemRequired}, func(c *Call) error { + var in struct { + ApplicationID string `json:"application_id"` + OrderID *string `json:"order_id"` + StartsAt *time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at"` + MemberScope *string `json:"member_scope"` + MemberOverrides []string `json:"member_overrides"` + Source json.RawMessage `json:"source"` + } + if err := c.Decode(&in); err != nil { + return err + } + if in.Source != nil { + return httpx.E(422, "invalid_source", "source is always org_seat here and isn't accepted.") + } + if in.ApplicationID == "" { + return httpx.Invalid("application_id is required.") + } + o, err := c.loadOrg(id) + if err != nil { + return err + } + a, err := c.loadApp(in.ApplicationID) + if err == db.ErrNotFound { + return httpx.NotFoundResource("application") + } + if err != nil { + return err + } + // Whoever controls the Application, never the Organization itself. + if ok, err := c.canManageApp("entitlements.manage", a); err != nil { + return err + } else if !ok { + return httpx.Forbidden("entitlements.manage") + } + if err := c.grantable(a); err != nil { + return err + } + starts := c.now + if in.StartsAt != nil { + starts = in.StartsAt.UTC() + } + if err := validateTerm("admin_grant", starts, in.EndsAt, in.OrderID, c.now); err != nil { + return err + } + scope := "all_members" + if in.MemberScope != nil { + scope = *in.MemberScope + if scope != "all_members" && scope != "allowlist" && scope != "denylist" { + var f httpx.Fields + f.Enum("member_scope", "all_members", "allowlist", "denylist") + return f.Err() + } + } + overrides := dedupe(in.MemberOverrides) + if len(overrides) > 1000 { + var f httpx.Fields + f.Max("member_overrides", "too_long", 1000) + return f.Err() + } + if err := c.checkOverridesAreMembers(o.ID, overrides); err != nil { + return err + } + t, err := c.loadTenant(a.TenantID) + if err != nil { + return err + } + if err := tenantWritable(t); err != nil { + return err + } + live, err := c.queryEnts(`e.organization_id = :o and e.application_id = :a and e.status in ('active','disabled')`, + db.Args{"o": o.ID, "a": a.ID}) + if err != nil { + return err + } + if len(live) > 0 { + return httpx.Conflict("entitlement_already_exists", "This Organization already has a live grant to this Application."). + With("existing_entitlement_id", live[0].ID) + } + // Plan checks on the Application's Tenant: every newly included + // active member who isn't already a seat counts as a new one. + if !c.testMode() && (t.Restricted || plans[t.Plan].Seats != nil) { + members, err := db.All[string](c.ctx, c.q, `select to_jsonb(m.user_id) from organization_memberships m + join users u on u.id = m.user_id + where m.organization_id = :o and m.status = 'active' and u.status = 'active' and not u.test_mode`, + db.Args{"o": o.ID}) + if err != nil { + return err + } + ss, err := c.seats(t.ID) + if err != nil { + return err + } + added := 0 + for _, uid := range members { + included := scope == "all_members" || + (scope == "allowlist" && slices.Contains(overrides, uid)) || + (scope == "denylist" && !slices.Contains(overrides, uid)) + if included && !ss.isSeat(uid) { + added++ + } + } + if added > 0 && t.Restricted { + return subscriptionRequired(t) + } + if l := plans[t.Plan].Seats; l != nil && added > 0 && ss.count()+added > *l { + return planLimitErr("seats", *l, ss.count(), t) + } + } + pairs, err := c.orgMemberPairs(o.ID, a.ID) + if err != nil { + return err + } + watch, err := c.watchAccess(pairs) + if err != nil { + return err + } + eid := ids.New(ids.Entitlement) + var mo any + if scope != "all_members" || len(overrides) > 0 { + mo = db.TextArray(overrides) + } + if _, err := c.q.Exec(c.ctx, `insert into entitlements (id, organization_id, application_id, tenant_id, status, source, + order_id, starts_at, ends_at, member_scope, member_overrides, test_mode) + values (:id, :o, :a, :t, 'active', 'org_seat', :ord, :st::timestamptz, :en::timestamptz, :ms, :mo::text[], :tm)`, + db.Args{"id": eid, "o": o.ID, "a": a.ID, "t": a.TenantID, "ord": in.OrderID, "st": db.Time(starts), + "en": db.NullTime(in.EndsAt), "ms": scope, "mo": mo, "tm": c.testMode()}); err != nil { + if _, ok := db.UniqueViolation(err); ok { + return httpx.Conflict("entitlement_already_exists", "This Organization already has a live grant to this Application.") + } + return err + } + e, err := c.loadEnt(eid) + if err != nil { + return err + } + if err := c.entitlementEffects("entitlement.granted", nil, e, nil); err != nil { + return err + } + if err := c.flip(watch, "entitlement_granted", "org_grant_changed"); err != nil { + return err + } + c.Versioned(e.version) + return c.Created(e) + }) +} diff --git a/internal/core/organizations_test.go b/internal/core/organizations_test.go new file mode 100644 index 0000000..0e2ec93 --- /dev/null +++ b/internal/core/organizations_test.go @@ -0,0 +1,169 @@ +package core_test + +import ( + "testing" + + "github.com/Adron/substratalapps.com/internal/testenv" +) + +func accessEventsFor(e *testenv.Env, typ, userID, appID string) int { + n := 0 + for _, ev := range e.Events(typ) { + p := ev["payload"].(map[string]any) + if p["user_id"] == userID && p["application_id"] == appID { + n++ + } + } + return n +} + +func TestOrganizations(t *testing.T) { + e := testenv.New(t) + admin := e.Signup() + org := e.Post("/v1/organizations", admin.Token, map[string]any{"name": "Acme Co."}).Expect(201) + oid := org.Str("id") + if org.JSON()["member_count"].(float64) != 1 { + t.Fatalf("org = %s", org.Body) + } + + // Inviting reveals nothing about whether the email had an account. + existing := e.Signup() + inv1 := e.Post("/v1/organizations/"+oid+"/members", admin.Token, map[string]any{"email": existing.Email}).Expect(201).JSON() + newAddr := "new+" + testenv.Unique() + "@example.com" + inv2 := e.Post("/v1/organizations/"+oid+"/members", admin.Token, map[string]any{"email": newAddr}).Expect(201).JSON() + for _, m := range []map[string]any{inv1, inv2} { + if m["membership_status"] != "pending" || m["display_name"] != nil { + t.Fatalf("invite leaked account state: %v", m) + } + } + e.Post("/v1/organizations/"+oid+"/members", admin.Token, map[string]any{"email": existing.Email}).ExpectErr(409, "already_member") + e.Post("/v1/organizations/"+oid+"/members", admin.Token, map[string]any{"user_id": existing.ID}).ExpectErr(403, "forbidden") + // A pending invitee can't see the Organization. + e.Get("/v1/organizations/"+oid, existing.Token).ExpectErr(404, "organization_not_found") + + // An org admin can't grant their own Organization an app. + app := e.App(system, nil) + e.PostIdem("/v1/organizations/"+oid+"/entitlements", admin.Token, testenv.Unique(), + map[string]any{"application_id": app}).ExpectErr(403, "forbidden") + grant := e.PostIdem("/v1/organizations/"+oid+"/entitlements", e.Admin, testenv.Unique(), + map[string]any{"application_id": app}).Expect(201) + gid := grant.Str("id") + if accessEventsFor(e, "access.granted", admin.ID, app) != 1 { + t.Fatal("the active member should get access.granted") + } + + // Accepting the invitation brings access with it. + e.Post("/v1/organizations/"+oid+"/members/me/accept", existing.Token, map[string]any{}).Expect(200) + if accessEventsFor(e, "access.granted", existing.ID, app) != 1 { + t.Fatal("accepting should fire access.granted") + } + eff := e.Get("/v1/users/"+existing.ID+"/apps/"+app+"/effective-permissions", e.Admin).Expect(200).JSON() + if eff["allowed"] != true { + t.Fatalf("member effective-permissions = %v", eff) + } + ents := e.Get("/v1/users/me/entitlements", existing.Token).Expect(200).JSON()["data"].([]any) + via := ents[0].(map[string]any)["granted_via"].(map[string]any) + if via["member_decision"] != "included" || via["organization_id"] != oid { + t.Fatalf("granted_via = %v", via) + } + + // Narrowing to a denylist revokes exactly the listed member. + e.Patch("/v1/entitlements/"+gid, admin.Token, map[string]any{"member_scope": "denylist", "member_overrides": []string{existing.ID}}).Expect(200) + if accessEventsFor(e, "access.revoked", existing.ID, app) != 1 || accessEventsFor(e, "access.revoked", admin.ID, app) != 0 { + t.Fatal("denylist should revoke only the listed member") + } + if len(e.Events("entitlement.member_scope_changed")) == 0 { + t.Fatal("expected entitlement.member_scope_changed") + } + e.Patch("/v1/entitlements/"+gid, admin.Token, map[string]any{"member_overrides": []string{"usr_01JAG3Z9X8QS3F6K2M4N5P6R7S"}}). + ExpectErr(422, "member_override_not_a_member") + // An org admin can toggle but not revoke or re-term. + e.Patch("/v1/entitlements/"+gid, admin.Token, map[string]any{"status": "revoked", "disabled_reason": "x"}).ExpectErr(403, "forbidden") + e.Patch("/v1/entitlements/"+gid, admin.Token, map[string]any{"status": "disabled", "disabled_reason": "budget"}).Expect(200) + + // The last org_admin can't leave or be demoted. + e.Delete("/v1/organizations/"+oid+"/members/me", admin.Token).ExpectErr(409, "last_org_admin") + e.Patch("/v1/organizations/"+oid+"/members/"+admin.ID, admin.Token, map[string]any{"role": "member"}).ExpectErr(409, "last_org_admin") + e.Patch("/v1/organizations/"+oid+"/members/"+existing.ID, admin.Token, map[string]any{"role": "org_admin"}).Expect(200) + e.Delete("/v1/organizations/"+oid+"/members/me", admin.Token).Expect(204) + + // Suspension is platform-only and freezes admin actions. + e.Patch("/v1/organizations/"+oid, existing.Token, map[string]any{"status": "suspended"}).ExpectErr(403, "forbidden") + e.Patch("/v1/organizations/"+oid, e.Admin, map[string]any{"status": "suspended", "reason": "ToS"}).Expect(200) + e.Patch("/v1/organizations/"+oid, existing.Token, map[string]any{"name": "Renamed"}).ExpectErr(403, "organization_suspended") + members := e.Get("/v1/organizations/"+oid+"/members", existing.Token).Expect(200).JSON()["data"].([]any) + if len(members) != 2 { // existing (active) + newAddr (pending) + t.Fatalf("members = %v", members) + } +} + +func TestProfilesAndSettings(t *testing.T) { + e := testenv.New(t) + u := e.Signup() + bad := e.Patch("/v1/users/me/profile", u.Token, map[string]any{"display_name": "", "contact_phone": "303-555-0142", + "avatar_url": "http://example.com/a.png"}).ExpectErr(422, "validation_failed") + if n := len(bad.JSON()["error"].(map[string]any)["details"].(map[string]any)["fields"].([]any)); n != 3 { + t.Fatalf("fields = %d: %s", n, bad.Body) + } + e.Patch("/v1/users/me/profile", u.Token, map[string]any{"locale": "fr-FR"}).ExpectErr(422, "read_only_field") + p := e.Patch("/v1/users/me/profile", u.Token, map[string]any{"display_name": "Jordan A.", "contact_phone": "+13035550142"}).Expect(200) + if p.Str("display_name") != "Jordan A." { + t.Fatalf("profile = %s", p.Body) + } + e.Patch("/v1/users/me/settings", u.Token, map[string]any{"timezone": "Mountain Time", "theme": "blue"}).ExpectErr(422, "validation_failed") + st := e.Patch("/v1/users/me/settings", u.Token, map[string]any{"timezone": "America/Denver", "locale": "en-GB", + "notifications": map[string]any{"sms": true}}).Expect(200).JSON() + notif := st["notifications"].(map[string]any) + if notif["email"] != true || notif["sms"] != true { + t.Fatalf("notifications should merge one level: %v", notif) + } + if e.Get("/v1/users/me/profile", u.Token).Str("locale") != "en-GB" { + t.Fatal("profile.locale should mirror settings") + } + + app := e.App(system, map[string]any{"settings_schema": map[string]any{"type": "object", "properties": map[string]any{ + "default_billable": map[string]any{"type": "boolean", "default": true}, + "week_start": map[string]any{"type": "string", "enum": []string{"sunday", "monday"}, "default": "sunday"}, + }}}) + sp := "/v1/users/me/apps/" + app + "/settings" + e.Get(sp, u.Token).ExpectErr(403, "entitlement_required") + e.Grant(e.Admin, u.ID, app) + r := e.Get(sp, u.Token).Expect(200).JSON() + if r["updated_at"] != nil || r["resolved"].(map[string]any)["week_start"] != "sunday" || + r["sources"].(map[string]any)["timezone"] != "global" { + t.Fatalf("defaults = %v", r) + } + viol := e.Patch(sp, u.Token, map[string]any{"overrides": map[string]any{"week_start": "friday", "color": "red"}}). + ExpectErr(422, "settings_schema_violation") + if n := len(viol.JSON()["error"].(map[string]any)["details"].(map[string]any)["fields"].([]any)); n != 2 { + t.Fatalf("violations = %s", viol.Body) + } + w := e.Patch(sp, u.Token, map[string]any{"overrides": map[string]any{"week_start": "monday", "theme": "light"}}).Expect(200).JSON() + if w["resolved"].(map[string]any)["week_start"] != "monday" || w["sources"].(map[string]any)["theme"] != "app_override" { + t.Fatalf("after write = %v", w) + } + // A schema change makes the override stale, never an error. + e.Patch("/v1/applications/"+app, e.Admin, map[string]any{"settings_schema": map[string]any{"type": "object", "properties": map[string]any{ + "week_start": map[string]any{"type": "integer", "minimum": 0, "maximum": 6, "default": 0}, + }}}).Expect(200) + stale := e.Get(sp, u.Token).Expect(200).JSON() + if stale["resolved"].(map[string]any)["week_start"].(float64) != 0 || len(stale["stale_overrides"].([]any)) != 1 { + t.Fatalf("stale = %v", stale) + } + stats := e.Get("/v1/applications/"+app, e.Admin).Expect(200).JSON()["settings_schema_stats"].(map[string]any) + if stats["stale_override_counts"].(map[string]any)["week_start"].(float64) != 1 { + t.Fatalf("stats = %v", stats) + } + e.Patch(sp, u.Token, map[string]any{"overrides": map[string]any{"week_start": nil}}).Expect(200) + + // AppProfile: lazy defaults, one-level merge, effective name. + ap := "/v1/users/me/apps/" + app + "/profile" + if e.Get(ap, u.Token).Expect(200).Str("effective_display_name") != "Jordan A." { + t.Fatal("effective_display_name should fall back to the global name") + } + e.Patch(ap, u.Token, map[string]any{"display_handle": "j.a", "custom": map[string]any{"seat": 14, "dept": "eng"}}).Expect(200) + got := e.Patch(ap, u.Token, map[string]any{"custom": map[string]any{"dept": nil}}).Expect(200).JSON() + if got["effective_display_name"] != "j.a" || len(got["custom"].(map[string]any)) != 1 { + t.Fatalf("app profile = %v", got) + } +} diff --git a/internal/core/principal.go b/internal/core/principal.go new file mode 100644 index 0000000..ccf438c --- /dev/null +++ b/internal/core/principal.go @@ -0,0 +1,226 @@ +package core + +import ( + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/auth" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/ids" +) + +// Principal is the authenticated caller: a User (platform access token) or +// an API Key. +type Principal struct { + Type string // "user" | "api_key" + ID string // usr_… or key_… + UserID string // set for users + SessionID string // set for users + AMR []string + TestMode bool + + // Perms is the caller's platform permission set: the union of a User's + // platform Roles, or an API Key's own permissions. + Perms map[string]bool + + KeyScope string // api keys: "platform" or an application_id + RestrictDestructive bool + EmailVerified bool +} + +// IsUser reports whether the caller is a User token. +func (p *Principal) IsUser() bool { return p.Type == "user" } + +// AppKey returns the Application an app-scoped API Key is confined to. +func (p *Principal) AppKey() (string, bool) { + if p.Type == "api_key" && p.KeyScope != "platform" { + return p.KeyScope, true + } + return "", false +} + +// PlatformKey reports whether the caller is a platform-scoped API Key. +func (p *Principal) PlatformKey() bool { return p.Type == "api_key" && p.KeyScope == "platform" } + +// accessClaims are the platform access token's claims (Auth → AuthSession). +type accessClaims struct { + Iss string `json:"iss"` + Aud string `json:"aud"` + Sub string `json:"sub"` + Sid string `json:"sid"` + AMR []string `json:"amr"` + EmailVerified bool `json:"email_verified"` + TestMode bool `json:"test_mode"` + Iat int64 `json:"iat"` + Exp int64 `json:"exp"` + Jti string `json:"jti"` +} + +const platformAudience = "substratal-platform" + +func bearer(c *Call) (string, error) { + h := c.r.Header.Get("Authorization") + scheme, tok, ok := strings.Cut(h, " ") + if !ok || !strings.EqualFold(scheme, "Bearer") || strings.TrimSpace(tok) == "" { + return "", httpx.ErrUnauthenticated() + } + return strings.TrimSpace(tok), nil +} + +// authenticate resolves the bearer credential. The transaction is in +// "any" mode while it runs; serve pins the principal's mode afterwards. +func (s *Server) authenticate(c *Call) (*Principal, error) { + tok, err := bearer(c) + if err != nil { + return nil, err + } + if strings.HasPrefix(tok, ids.APIKeyLive) || strings.HasPrefix(tok, ids.APIKeyTest) { + return s.authenticateKey(c, tok) + } + return s.authenticateUser(c, tok) +} + +func (s *Server) authenticateKey(c *Call, secret string) (*Principal, error) { + var k struct { + ID string `json:"id"` + Scope string `json:"scope"` + Permissions []string `json:"permissions"` + TestMode bool `json:"test_mode"` + RestrictDestructive bool `json:"restrict_destructive"` + ExpiresAt *time.Time `json:"expires_at"` + RevokedAt *time.Time `json:"revoked_at"` + LastUsedAt *time.Time `json:"last_used_at"` + } + err := db.One(c.ctx, c.q, &k, `select to_jsonb(k) from api_keys k where secret_hash = :h`, + db.Args{"h": ids.Hash(secret)}) + if err == db.ErrNotFound { + return nil, httpx.ErrUnauthenticated() + } + if err != nil { + return nil, err + } + if k.RevokedAt != nil || (k.ExpiresAt != nil && !k.ExpiresAt.After(c.now)) { + return nil, httpx.ErrUnauthenticated() + } + // The secret's mode prefix must agree with the row (defense in depth). + if strings.HasPrefix(secret, ids.APIKeyTest) != k.TestMode { + return nil, httpx.ErrUnauthenticated() + } + if k.LastUsedAt == nil || c.now.Sub(*k.LastUsedAt) > time.Minute { + if _, err := c.q.Exec(c.ctx, `update api_keys set last_used_at = now() where id = :id`, db.Args{"id": k.ID}); err != nil { + return nil, err + } + } + p := &Principal{Type: "api_key", ID: k.ID, TestMode: k.TestMode, KeyScope: k.Scope, + RestrictDestructive: k.RestrictDestructive, Perms: map[string]bool{}} + for _, perm := range k.Permissions { + p.Perms[perm] = true + } + return p, nil +} + +func (s *Server) authenticateUser(c *Call, tok string) (*Principal, error) { + var cl accessClaims + if err := auth.VerifyJWT(tok, s.keys, &cl); err != nil { + return nil, httpx.ErrUnauthenticated() + } + if cl.Iss != s.Issuer || cl.Aud != platformAudience || cl.Exp <= c.now.Unix() || + !strings.HasPrefix(cl.Jti, ids.AccessTokenJTI) || cl.Sub == "" || cl.Sid == "" { + return nil, httpx.ErrUnauthenticated() + } + // One round trip: the session (logout is immediate because every + // request checks it), the User's standing, and the platform permission + // set from their platform Roles (resolved fresh, never carried in the + // token, so a Role change applies on the very next call). + var row struct { + RevokedAt *time.Time `json:"revoked_at"` + IdleExpires time.Time `json:"idle_expires_at"` + AbsExpires time.Time `json:"absolute_expires_at"` + UserStatus string `json:"user_status"` + TestMode bool `json:"test_mode"` + EmailVerified bool `json:"email_verified"` + Perms []string `json:"perms"` + } + err := db.One(c.ctx, c.q, &row, `select jsonb_build_object( + 'revoked_at', s.revoked_at, 'idle_expires_at', s.idle_expires_at, + 'absolute_expires_at', s.absolute_expires_at, + 'user_status', u.status, 'test_mode', u.test_mode, 'email_verified', u.email_verified, + 'perms', coalesce((select array_agg(distinct perm) from user_role_assignments a + join roles r on r.id = a.role_id, unnest(r.permissions) perm + where a.user_id = u.id and r.scope = 'platform'), '{}')) + from sessions s join users u on u.id = s.user_id + where s.id = :sid and s.user_id = :sub`, db.Args{"sid": cl.Sid, "sub": cl.Sub}) + if err == db.ErrNotFound { + return nil, httpx.ErrUnauthenticated() + } + if err != nil { + return nil, err + } + if row.TestMode != cl.TestMode { + return nil, httpx.ErrUnauthenticated() + } + if row.RevokedAt != nil || !row.AbsExpires.After(c.now) || row.UserStatus == "suspended" || row.UserStatus == "deleted" { + return nil, httpx.ErrSessionRevoked() + } + p := &Principal{Type: "user", ID: cl.Sub, UserID: cl.Sub, SessionID: cl.Sid, AMR: cl.AMR, + TestMode: row.TestMode, EmailVerified: row.EmailVerified, Perms: map[string]bool{}} + for _, perm := range row.Perms { + p.Perms[perm] = true + } + return p, nil +} + +// ── Permission checks ────────────────────────────────────────────────── + +// can is has_platform_permission (Access Control → The algorithm): a +// User's platform Roles, or a platform-scoped API Key's own permissions. +// App-scoped keys never hold platform permissions unconfined. +func (c *Call) can(perm string) bool { + if c.p == nil { + return false + } + if c.p.IsUser() || c.p.PlatformKey() { + return c.p.Perms[perm] + } + return false +} + +// require returns 403 forbidden unless can(perm). +func (c *Call) require(perm string) error { + if c.can(perm) { + return nil + } + return httpx.Forbidden(perm) +} + +// appKeyCan reports whether an app-scoped key confined to appID carries +// perm (App-confined permissions). +func (c *Call) appKeyCan(perm, appID string) bool { + app, ok := c.p.AppKey() + return ok && app == appID && c.p.Perms[perm] +} + +// selfID resolves a {id} path parameter, where "me" means the caller. +func (c *Call) selfID(id string) (string, error) { + if id == "me" { + if c.p == nil || !c.p.IsUser() { + return "", httpx.ErrUserTokenRequired() + } + return c.p.UserID, nil + } + return id, nil +} + +// isSelf reports whether userID is the calling User. +func (c *Call) isSelf(userID string) bool { + return c.p != nil && c.p.IsUser() && c.p.UserID == userID +} + +// actor is the audit-event actor for this call. +func (c *Call) actor() (typ, id string) { + if c.p == nil { + return "system", "system" + } + return c.p.Type, c.p.ID +} diff --git a/internal/core/profiles.go b/internal/core/profiles.go new file mode 100644 index 0000000..44674cd --- /dev/null +++ b/internal/core/profiles.go @@ -0,0 +1,785 @@ +package core + +import ( + "encoding/json" + "net/http" + "regexp" + "sort" + "time" + _ "time/tzdata" // IANA zones for timezone validation, independent of the host image + + "golang.org/x/text/language" + + "github.com/Adron/substratalapps.com/internal/api/gen" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/schema" +) + +const maxFreeformBytes = 16 << 10 + +// ── Global Profile ───────────────────────────────────────────────────── + +type profile struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` + AvatarURL *string `json:"avatar_url"` + ContactEmail *string `json:"contact_email"` + ContactPhone *string `json:"contact_phone"` + Locale string `json:"locale"` + UpdatedAt time.Time `json:"updated_at"` + version int +} + +func (c *Call) loadProfile(uid string) (profile, error) { + var x struct { + profile + V int `json:"version_"` + } + err := db.One(c.ctx, c.q, &x, `select jsonb_build_object('user_id', p.user_id, 'display_name', p.display_name, + 'avatar_url', p.avatar_url, 'contact_email', p.contact_email, 'contact_phone', p.contact_phone, + 'locale', coalesce(s.locale, 'en-US'), 'updated_at', p.updated_at, 'version_', p.version) + from profiles p left join settings s on s.user_id = p.user_id where p.user_id = :u`, db.Args{"u": uid}) + if err == db.ErrNotFound { + return profile{}, httpx.NotFoundResource("user") + } + x.profile.version = x.V + return x.profile, err +} + +var e164 = regexp.MustCompile(`^\+[1-9][0-9]{7,14}$`) + +func (s *Server) ProfilesGet(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe) { + s.serve(w, r, opts{op: "profiles.get"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + u, err := c.visibleUser(uid) + if err != nil { + return err + } + if ok, err := c.canReadUser(u); err != nil { + return err + } else if !ok { + return httpx.NotFoundResource("user") + } + p, err := c.loadProfile(uid) + if err != nil { + return err + } + c.Versioned(p.version) + return c.OK(p) + }) +} + +func (s *Server) ProfilesUpdate(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe, params gen.ProfilesUpdateParams) { + s.serve(w, r, opts{op: "profiles.update"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + if !c.isSelf(uid) && !c.can("users.manage") { + return httpx.Forbidden("users.manage") + } + if _, err := c.visibleUser(uid); err != nil { + return err + } + before, err := c.loadProfile(uid) + if err != nil { + return err + } + if err := httpx.CheckIfMatch(c.r, before.version); err != nil { + return err + } + p, err := c.Patch() + if err != nil { + return err + } + if err := p.RejectReadOnly("locale", "user_id", "updated_at"); err != nil { + return err + } + var f httpx.Fields + next := before + if v, present := p.String("display_name", &f); present { + switch { + case v == nil: + f.Add("display_name", "required").Message = "this field isn't nullable" + case len([]rune(*v)) < 1: + f.Add("display_name", "too_short").Min = ip(1) + case len([]rune(*v)) > 100: + f.Max("display_name", "too_long", 100) + default: + next.DisplayName = *v + } + } + if v, present := p.String("avatar_url", &f); present { + if v != nil && (len(*v) > 2048 || !httpsURL(*v)) { + f.Add("avatar_url", "invalid_format").Allowed = []string{"https"} + } + next.AvatarURL = v + } + if v, present := p.String("contact_email", &f); present { + if v != nil { + if _, ok := validEmail(*v); !ok { + f.Add("contact_email", "invalid_format") + } + } + next.ContactEmail = v + } + if v, present := p.String("contact_phone", &f); present { + if v != nil && !e164.MatchString(*v) { + f.Add("contact_phone", "invalid_format").Pattern = "E.164" + } + next.ContactPhone = v + } + if err := f.Err(); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update profiles set display_name = :d, avatar_url = :a, contact_email = :e, + contact_phone = :p where user_id = :u`, + db.Args{"d": next.DisplayName, "a": next.AvatarURL, "e": next.ContactEmail, "p": next.ContactPhone, "u": uid}); err != nil { + return err + } + after, err := c.loadProfile(uid) + if err != nil { + return err + } + if !c.isSelf(uid) { + if err := c.audit(Audit{Action: "profile.updated", TargetType: "user", TargetID: uid, TargetUserID: &uid, + Before: before, After: after}); err != nil { + return err + } + } + c.Versioned(after.version) + return c.OK(after) + }) +} + +// ── Global Settings ──────────────────────────────────────────────────── + +type settingsObj struct { + UserID string `json:"user_id"` + Locale string `json:"locale"` + Timezone string `json:"timezone"` + Theme string `json:"theme"` + Notifications map[string]bool `json:"notifications"` + UpdatedAt time.Time `json:"updated_at"` + version int +} + +func (c *Call) loadSettings(uid string) (settingsObj, error) { + var x struct { + settingsObj + V int `json:"version"` + } + err := db.One(c.ctx, c.q, &x, `select to_jsonb(s) from settings s where user_id = :u`, db.Args{"u": uid}) + if err == db.ErrNotFound { + return settingsObj{}, httpx.NotFoundResource("user") + } + if x.settingsObj.Notifications == nil { + x.settingsObj.Notifications = map[string]bool{} + } + x.settingsObj.version = x.V + return x.settingsObj, err +} + +var channelRe = regexp.MustCompile(`^[a-z][a-z0-9_]{0,31}$`) + +func validLocale(s string) bool { + if s == "" || len(s) > 35 { + return false + } + _, err := language.Parse(s) + return err == nil +} + +func validTimezone(s string) bool { + if s == "" || s == "Local" { + return false + } + _, err := time.LoadLocation(s) + return err == nil +} + +// mergeNotifications merges a channel map one level deep; null removes. +func mergeNotifications(base map[string]bool, patch map[string]*bool, field string, f *httpx.Fields) map[string]bool { + out := map[string]bool{} + for k, v := range base { + out[k] = v + } + for k, v := range patch { + if !channelRe.MatchString(k) { + f.Add(field+"."+k, "invalid_format").Pattern = channelRe.String() + continue + } + if v == nil { + delete(out, k) + } else { + out[k] = *v + } + } + if len(out) > 20 { + f.Max(field, "too_long", 20) + } + return out +} + +func (s *Server) SettingsGet(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe) { + s.serve(w, r, opts{op: "settings.get"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + if !c.isSelf(uid) && !c.can("users.list") && !c.can("users.manage") { + return httpx.Forbidden("users.list") + } + if _, err := c.visibleUser(uid); err != nil { + return err + } + st, err := c.loadSettings(uid) + if err != nil { + return err + } + c.Versioned(st.version) + return c.OK(st) + }) +} + +func (s *Server) SettingsUpdate(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe, params gen.SettingsUpdateParams) { + s.serve(w, r, opts{op: "settings.update"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + if !c.isSelf(uid) && !c.can("users.manage") { + return httpx.Forbidden("users.manage") + } + if _, err := c.visibleUser(uid); err != nil { + return err + } + before, err := c.loadSettings(uid) + if err != nil { + return err + } + if err := httpx.CheckIfMatch(c.r, before.version); err != nil { + return err + } + p, err := c.Patch() + if err != nil { + return err + } + if err := p.RejectReadOnly("user_id", "updated_at"); err != nil { + return err + } + var f httpx.Fields + p.RequireNonNull(&f, "locale", "timezone", "theme", "notifications") + next := before + if v, present := p.String("locale", &f); present && v != nil { + if !validLocale(*v) { + f.Add("locale", "unknown_value") + } + next.Locale = *v + } + if v, present := p.String("timezone", &f); present && v != nil { + if !validTimezone(*v) { + f.Add("timezone", "unknown_value") + } + next.Timezone = *v + } + if v, present := p.String("theme", &f); present && v != nil { + if *v != "light" && *v != "dark" && *v != "system" { + f.Enum("theme", "light", "dark", "system") + } + next.Theme = *v + } + if p.Has("notifications") && !p.IsNull("notifications") { + var n map[string]*bool + if p.Get("notifications", &n, &f) { + next.Notifications = mergeNotifications(before.Notifications, n, "notifications", &f) + } + } + if err := f.Err(); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update settings set locale = :l, timezone = :tz, theme = :th, + notifications = :n::jsonb where user_id = :u`, + db.Args{"l": next.Locale, "tz": next.Timezone, "th": next.Theme, "n": db.JSON(next.Notifications), "u": uid}); err != nil { + return err + } + after, err := c.loadSettings(uid) + if err != nil { + return err + } + if !c.isSelf(uid) { + if err := c.audit(Audit{Action: "settings.updated", TargetType: "user", TargetID: uid, TargetUserID: &uid, + Before: before, After: after}); err != nil { + return err + } + } + c.Versioned(after.version) + return c.OK(after) + }) +} + +// ── Per-app access gate (Profiles/Settings: self, app key, support) ──── + +// appDataGate authorizes per-app profile/settings access: the User and the +// app's own key need the User to have active access right now; users.list +// reads and users.manage reads/writes regardless. +func (c *Call) appDataGate(uid, appID string, write bool) (appRow, error) { + a, err := c.loadApp(appID) + if err == db.ErrNotFound { + return a, httpx.NotFoundResource("application") + } + if err != nil { + return a, err + } + if key, ok := c.p.AppKey(); ok && key != appID { + return a, httpx.NotFoundResource("application") + } + u, err := c.visibleUser(uid) + if err != nil { + return a, err + } + if c.can("users.manage") || (!write && c.can("users.list")) { + return a, nil + } + _, isKey := c.p.AppKey() + if !isKey && !c.isSelf(uid) { + if write { + return a, httpx.Forbidden("users.manage") + } + return a, httpx.Forbidden("users.list") + } + res, err := c.resolveOne(u.ID, appID) + if err != nil { + return a, err + } + if !res.Allowed { + return a, httpx.E(403, "entitlement_required", "This user has no active entitlement to "+appID+"."). + With("application_id", appID).With("entitlement_status", res.Res.Status) + } + return a, nil +} + +// adminWrite reports whether a write to another User's data should be +// audited (an admin acting, not the User or the app's own key). +func (c *Call) adminWrite(uid string) bool { + _, isKey := c.p.AppKey() + return !isKey && !c.isSelf(uid) +} + +// ── AppProfile ───────────────────────────────────────────────────────── + +type appProfile struct { + UserID string `json:"user_id"` + ApplicationID string `json:"application_id"` + DisplayHandle *string `json:"display_handle"` + EffectiveDisplayName string `json:"effective_display_name"` + Custom json.RawMessage `json:"custom"` + UpdatedAt *time.Time `json:"updated_at"` + version int +} + +func (c *Call) loadAppProfile(uid, appID string) (appProfile, error) { + var x struct { + appProfile + V *int `json:"version_"` + } + err := db.One(c.ctx, c.q, &x, `select jsonb_build_object('user_id', p.user_id, 'application_id', :a::text, + 'display_handle', ap.display_handle, + 'effective_display_name', coalesce(ap.display_handle, p.display_name), + 'custom', coalesce(ap.custom, '{}'::jsonb), 'updated_at', ap.updated_at, 'version_', ap.version) + from profiles p left join app_profiles ap on ap.user_id = p.user_id and ap.application_id = :a + where p.user_id = :u`, db.Args{"u": uid, "a": appID}) + if err == db.ErrNotFound { + return appProfile{}, httpx.NotFoundResource("user") + } + if x.V != nil { + x.appProfile.version = *x.V + } + return x.appProfile, err +} + +func (s *Server) AppProfilesGet(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe, appID gen.AppId) { + s.serve(w, r, opts{op: "appProfiles.get"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + if _, err := c.appDataGate(uid, appID, false); err != nil { + return err + } + ap, err := c.loadAppProfile(uid, appID) + if err != nil { + return err + } + if ap.version > 0 { + c.Versioned(ap.version) + } + return c.OK(ap) + }) +} + +func (s *Server) AppProfilesUpdate(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe, appID gen.AppId, params gen.AppProfilesUpdateParams) { + s.serve(w, r, opts{op: "appProfiles.update"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + a, err := c.appDataGate(uid, appID, true) + if err != nil { + return err + } + before, err := c.loadAppProfile(uid, appID) + if err != nil { + return err + } + if before.version > 0 { + if err := httpx.CheckIfMatch(c.r, before.version); err != nil { + return err + } + } + t, err := c.loadTenant(a.TenantID) + if err != nil { + return err + } + if err := tenantWritable(t); err != nil { + return err + } + p, err := c.Patch() + if err != nil { + return err + } + if err := p.RejectReadOnly("effective_display_name", "user_id", "application_id", "updated_at"); err != nil { + return err + } + var f httpx.Fields + p.RequireNonNull(&f, "custom") + handle := before.DisplayHandle + if v, present := p.String("display_handle", &f); present { + if v != nil && (len([]rune(*v)) < 1 || len([]rune(*v)) > 64) { + f.Max("display_handle", "too_long", 64) + } + handle = v + } + custom := map[string]json.RawMessage{} + _ = json.Unmarshal(before.Custom, &custom) + if p.Has("custom") && !p.IsNull("custom") { + var patch map[string]json.RawMessage + if p.Get("custom", &patch, &f) { + for k, v := range patch { + if string(v) == "null" { + delete(custom, k) + } else { + custom[k] = v + } + } + } + } + customJSON := db.JSON(custom) + if len(customJSON) > maxFreeformBytes { + f.Max("custom", "too_long", maxFreeformBytes) + } + if err := f.Err(); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `insert into app_profiles (user_id, application_id, tenant_id, display_handle, custom, test_mode) + values (:u, :a, :t, :h, :c::jsonb, :tm) + on conflict (user_id, application_id) do update set display_handle = excluded.display_handle, custom = excluded.custom`, + db.Args{"u": uid, "a": appID, "t": a.TenantID, "h": handle, "c": customJSON, "tm": c.testMode()}); err != nil { + return err + } + after, err := c.loadAppProfile(uid, appID) + if err != nil { + return err + } + if c.adminWrite(uid) { + if err := c.audit(Audit{Action: "profile.updated", TargetType: "user", TargetID: uid, TargetUserID: &uid, + ApplicationID: &a.ID, Before: before, After: after}); err != nil { + return err + } + } + c.Versioned(after.version) + return c.OK(after) + }) +} + +// ── AppSettings (Settings → Resolution rules) ────────────────────────── + +type appSettingsResolved struct { + UserID string `json:"user_id"` + ApplicationID string `json:"application_id"` + Resolved map[string]any `json:"resolved"` + Sources map[string]string `json:"sources"` + Overrides map[string]json.RawMessage `json:"overrides"` + StaleOverrides []string `json:"stale_overrides"` + UpdatedAt *time.Time `json:"updated_at"` + version int +} + +// validReservedOverride applies the global Settings rules to a per-app +// override of a reserved key. +func validReservedOverride(key string, v json.RawMessage) *schema.Issue { + path := "overrides." + key + switch key { + case "locale", "timezone": + var s string + if json.Unmarshal(v, &s) != nil || (key == "locale" && !validLocale(s)) || (key == "timezone" && !validTimezone(s)) { + return &schema.Issue{Path: path, Code: "unknown_value"} + } + case "theme": + var s string + if json.Unmarshal(v, &s) != nil || (s != "light" && s != "dark" && s != "system") { + return &schema.Issue{Path: path, Code: "enum_mismatch", Allowed: []string{"light", "dark", "system"}} + } + case "notifications": + var m map[string]bool + if json.Unmarshal(v, &m) != nil { + return &schema.Issue{Path: path, Code: "invalid_format"} + } + for k := range m { + if !channelRe.MatchString(k) { + return &schema.Issue{Path: path + "." + k, Code: "invalid_format", Pattern: channelRe.String()} + } + } + } + return nil +} + +func (c *Call) resolveAppSettings(uid string, a appRow) (appSettingsResolved, error) { + global, err := c.loadSettings(uid) + if err != nil { + return appSettingsResolved{}, err + } + var row struct { + Overrides map[string]json.RawMessage `json:"overrides"` + UpdatedAt *time.Time `json:"updated_at"` + Version int `json:"version"` + } + err = db.One(c.ctx, c.q, &row, `select jsonb_build_object('overrides', overrides, 'updated_at', updated_at, + 'version', version) from app_settings where user_id = :u and application_id = :a`, db.Args{"u": uid, "a": a.ID}) + if err != nil && err != db.ErrNotFound { + return appSettingsResolved{}, err + } + if row.Overrides == nil { + row.Overrides = map[string]json.RawMessage{} + } + sch, _, _ := schema.Parse(a.SettingsSchema) + out := appSettingsResolved{UserID: uid, ApplicationID: a.ID, Resolved: map[string]any{}, Sources: map[string]string{}, + Overrides: row.Overrides, StaleOverrides: []string{}, UpdatedAt: row.UpdatedAt, version: row.Version} + valid := func(k string) (json.RawMessage, bool) { + v, ok := row.Overrides[k] + if !ok { + return nil, false + } + var bad bool + if schema.IsReserved(k) { + bad = validReservedOverride(k, v) != nil + } else if p, declared := sch.Props[k]; !declared || len(p.Validate(v, k)) > 0 { + bad = true + } + if bad { + out.StaleOverrides = append(out.StaleOverrides, k) + return nil, false + } + return v, true + } + globals := map[string]any{"locale": global.Locale, "timezone": global.Timezone, "theme": global.Theme} + for _, k := range []string{"locale", "timezone", "theme"} { + if v, ok := valid(k); ok { + var x any + _ = json.Unmarshal(v, &x) + out.Resolved[k], out.Sources[k] = x, "app_override" + } else { + out.Resolved[k], out.Sources[k] = globals[k], "global" + } + } + notif := map[string]bool{} + for k, v := range global.Notifications { + notif[k] = v + } + out.Sources["notifications"] = "global" + if v, ok := valid("notifications"); ok { + var m map[string]bool + _ = json.Unmarshal(v, &m) + for k, b := range m { + notif[k] = b + } + out.Sources["notifications"] = "app_override" + } + out.Resolved["notifications"] = notif + for _, k := range sch.Order { + if v, ok := valid(k); ok { + var x any + _ = json.Unmarshal(v, &x) + out.Resolved[k], out.Sources[k] = x, "app_override" + } else if d := sch.Props[k].Default; d != nil { + var x any + _ = json.Unmarshal(d, &x) + out.Resolved[k], out.Sources[k] = x, "app_default" + } + } + // Overrides for keys no longer declared at all are stale too. + for k := range row.Overrides { + if _, declared := sch.Props[k]; !declared && !schema.IsReserved(k) { + out.StaleOverrides = append(out.StaleOverrides, k) + } + } + sort.Strings(out.StaleOverrides) + out.StaleOverrides = dedupe(out.StaleOverrides) + return out, nil +} + +func (s *Server) AppSettingsGet(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe, appID gen.AppId) { + s.serve(w, r, opts{op: "appSettings.get"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + a, err := c.appDataGate(uid, appID, false) + if err != nil { + return err + } + out, err := c.resolveAppSettings(uid, a) + if err != nil { + return err + } + if out.version > 0 { + c.Versioned(out.version) + } + return c.OK(out) + }) +} + +func (s *Server) AppSettingsUpdate(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe, appID gen.AppId, params gen.AppSettingsUpdateParams) { + s.serve(w, r, opts{op: "appSettings.update"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + a, err := c.appDataGate(uid, appID, true) + if err != nil { + return err + } + before, err := c.resolveAppSettings(uid, a) + if err != nil { + return err + } + if before.version > 0 { + if err := httpx.CheckIfMatch(c.r, before.version); err != nil { + return err + } + } + t, err := c.loadTenant(a.TenantID) + if err != nil { + return err + } + if err := tenantWritable(t); err != nil { + return err + } + p, err := c.Patch() + if err != nil { + return err + } + if err := p.RejectReadOnly("resolved", "sources", "stale_overrides", "user_id", "application_id", "updated_at"); err != nil { + return err + } + var patch map[string]json.RawMessage + var f httpx.Fields + if !p.Get("overrides", &patch, &f) { + if err := f.Err(); err != nil { + return err + } + return httpx.Invalid("overrides is required.") + } + sch, _, _ := schema.Parse(a.SettingsSchema) + var issues []schema.Issue + merged := map[string]json.RawMessage{} + for k, v := range before.Overrides { + merged[k] = v + } + keys := make([]string, 0, len(patch)) + for k := range patch { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := patch[k] + if string(v) == "null" { + delete(merged, k) // null always clears, never validated + continue + } + if schema.IsReserved(k) { + if is := validReservedOverride(k, v); is != nil { + issues = append(issues, *is) + continue + } + if k == "notifications" { + // One level deep over the existing override. + var cur, add map[string]*bool + _ = json.Unmarshal(merged[k], &cur) + _ = json.Unmarshal(v, &add) + if cur == nil { + cur = map[string]*bool{} + } + for ck, cv := range add { + cur[ck] = cv + } + v = json.RawMessage(db.JSON(cur)) + } + merged[k] = v + continue + } + prop, declared := sch.Props[k] + if !declared { + issues = append(issues, schema.Issue{Path: "overrides." + k, Code: "undeclared_key"}) + continue + } + if is := prop.Validate(v, "overrides."+k); len(is) > 0 { + issues = append(issues, is...) + continue + } + merged[k] = v + } + if len(issues) > 0 { + msg := "1 override failed validation against " + a.ID + "'s settings_schema." + if len(issues) > 1 { + msg = itoa(len(issues)) + " overrides failed validation against " + a.ID + "'s settings_schema." + } + return httpx.E(422, "settings_schema_violation", msg).With("fields", issues) + } + mergedJSON := db.JSON(merged) + if len(mergedJSON) > maxFreeformBytes { + f.Max("overrides", "too_long", maxFreeformBytes) + return f.Err() + } + if _, err := c.q.Exec(c.ctx, `insert into app_settings (user_id, application_id, tenant_id, overrides, test_mode) + values (:u, :a, :t, :o::jsonb, :tm) + on conflict (user_id, application_id) do update set overrides = excluded.overrides`, + db.Args{"u": uid, "a": a.ID, "t": a.TenantID, "o": mergedJSON, "tm": c.testMode()}); err != nil { + return err + } + after, err := c.resolveAppSettings(uid, a) + if err != nil { + return err + } + if c.adminWrite(uid) { + if err := c.audit(Audit{Action: "settings.updated", TargetType: "user", TargetID: uid, TargetUserID: &uid, + ApplicationID: &a.ID, Before: map[string]any{"overrides": before.Overrides}, + After: map[string]any{"overrides": after.Overrides}}); err != nil { + return err + } + } + c.Versioned(after.version) + return c.OK(after) + }) +} + +func itoa(n int) string { return jsonNumber(n) } + +func jsonNumber(n int) string { + b, _ := json.Marshal(n) + return string(b) +} diff --git a/internal/core/quickstart_test.go b/internal/core/quickstart_test.go new file mode 100644 index 0000000..df78a45 --- /dev/null +++ b/internal/core/quickstart_test.go @@ -0,0 +1,98 @@ +package core_test + +import ( + "os" + "slices" + "testing" + + "github.com/Adron/substratalapps.com/internal/testenv" +) + +func TestMain(m *testing.M) { + code := m.Run() + testenv.Drop() + os.Exit(code) +} + +// TestQuickstart runs docs/quickstart.md end to end: PLAN.md's Phase 1 +// acceptance test. +func TestQuickstart(t *testing.T) { + e := testenv.New(t) + app := e.App("usr_01JAG0SYSTEM00000000000000", map[string]any{"default_app_role": nil}) + addr := "jordan+" + testenv.Unique() + "@example.com" + + // 1. Create a user. + u := e.Post("/v1/users", e.Admin, map[string]any{"email": addr, "status": "invited"}).Expect(201) + uid := u.Str("id") + if u.Str("status") != "invited" { + t.Fatalf("status = %s", u.Str("status")) + } + + // 2. They own nothing yet. + list := e.Get("/v1/users/"+uid+"/entitlements", e.Admin).Expect(200).JSON() + if len(list["data"].([]any)) != 0 { + t.Fatalf("expected no entitlements: %v", list) + } + + // 3. Grant access. + ent := e.PostIdem("/v1/users/"+uid+"/entitlements", e.Admin, testenv.Unique(), + map[string]any{"application_id": app, "source": "admin_grant"}).Expect(201) + if ent.Str("status") != "active" { + t.Fatalf("entitlement status = %s", ent.Str("status")) + } + entID := ent.Str("id") + + // 4. Not allowed while invited; allowed once active. + eff := e.Get("/v1/users/"+uid+"/apps/"+app+"/effective-permissions", e.Admin).Expect(200).JSON() + if eff["allowed"] != false || eff["user_status"] != "invited" || eff["entitlement_status"] != "active" { + t.Fatalf("invited effective-permissions: %v", eff) + } + e.Patch("/v1/users/"+uid, e.Admin, map[string]any{"status": "active"}).Expect(200) + eff = e.Get("/v1/users/"+uid+"/apps/"+app+"/effective-permissions", e.Admin).Expect(200).JSON() + if eff["allowed"] != true || len(eff["effective_permissions"].([]any)) != 0 { + t.Fatalf("active effective-permissions: %v", eff) + } + + // 5. Assign a role inside the app; its permissions appear. + slug := app[len("app_"):] + role := "role_" + slug + "_admin" + e.Patch("/v1/roles/"+role, e.Admin, map[string]any{"permissions": []string{"app." + slug + ".export"}}).Expect(200) + e.Post("/v1/users/"+uid+"/roles/"+role, e.Admin, map[string]any{}).Expect(201) + eff = e.Get("/v1/users/"+uid+"/apps/"+app+"/effective-permissions", e.Admin).Expect(200).JSON() + perms := toStrings(eff["effective_permissions"]) + if !slices.Contains(perms, "app."+slug+".export") { + t.Fatalf("permissions = %v", perms) + } + + // 6. Turn it off: not allowed, permissions empty, role untouched. + e.Patch("/v1/entitlements/"+entID, e.Admin, map[string]any{"status": "disabled", "disabled_reason": "quickstart_demo"}).Expect(200) + eff = e.Get("/v1/users/"+uid+"/apps/"+app+"/effective-permissions", e.Admin).Expect(200).JSON() + if eff["allowed"] != false || eff["entitlement_status"] != "disabled" || len(eff["effective_permissions"].([]any)) != 0 { + t.Fatalf("disabled effective-permissions: %v", eff) + } + roles := e.Get("/v1/users/"+uid+"/roles", e.Admin).Expect(200).JSON()["data"].([]any) + found := false + for _, r := range roles { + found = found || r.(map[string]any)["role_id"] == role + } + if !found { + t.Fatal("role assignment should survive the disable") + } + + // The toggle wrote its audit event and webhook events. + if len(e.Events("entitlement.disabled")) == 0 || len(e.Events("access.revoked")) == 0 { + t.Fatal("expected entitlement.disabled and access.revoked outbox events") + } + audit := e.Get("/v1/audit-events?target_user_id="+uid+"&action=entitlement.disabled", e.Admin).Expect(200).JSON() + if len(audit["data"].([]any)) != 1 { + t.Fatalf("audit = %v", audit) + } +} + +func toStrings(v any) []string { + var out []string + for _, x := range v.([]any) { + out = append(out, x.(string)) + } + return out +} diff --git a/internal/core/ratelimit.go b/internal/core/ratelimit.go new file mode 100644 index 0000000..fb2c65f --- /dev/null +++ b/internal/core/ratelimit.go @@ -0,0 +1,115 @@ +package core + +import ( + "strconv" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" +) + +// limit is one rate-limit rule (NFR → Rate limiting). +type limit struct { + n int + window time.Duration +} + +var ( + perMinute = func(n int) limit { return limit{n, time.Minute} } + perHour = func(n int) limit { return limit{n, time.Hour} } + perDay = func(n int) limit { return limit{n, 24 * time.Hour} } +) + +// principalLimits overrides the default 100/min per API Key or session. +var principalLimits = map[string]limit{ + "users.create": perMinute(20), + "entitlements.grant": perMinute(20), + "organizations.grantEntitlement": perMinute(20), + "permissions.getEffective": perMinute(300), + "auth.createAppToken": perMinute(300), +} + +// publicLimits are per client IP on unauthenticated endpoints. +var publicLimits = map[string]limit{ + "auth.login": perMinute(10), + "auth.signup": perMinute(10), + "auth.verifyMfa": perMinute(10), + "auth.forgotPassword": perMinute(5), + "auth.resendEmailVerification": perMinute(5), + "auth.exchangeOAuthToken": perMinute(300), + "auth.refreshToken": perMinute(60), // plus 30/min per session, in the handler +} + +const defaultPrincipalLimit = 100 +const defaultPublicLimit = 20 + +// hit increments bucket's counter for the current window and reports +// whether the request is within n. Counters are written with autocommit so +// a request that fails (and rolls back) still counts. +func (c *Call) hit(bucket string, l limit, setHeaders bool) error { + if c.s.DisableRateLimits { + return nil + } + window := c.now.Truncate(l.window) + var count int + rows, err := c.s.DB.Autocommit().Query(c.ctx, `insert into rate_limit_counters (bucket, window_start, count) + values (:b, :w::timestamptz, 1) + on conflict (bucket, window_start) do update set count = rate_limit_counters.count + 1 + returning to_jsonb(count)`, db.Args{"b": bucket, "w": db.Time(window)}) + if err != nil { + return err + } + if len(rows) == 1 { + count, _ = strconv.Atoi(strings.TrimSpace(string(rows[0]))) + } + reset := int(window.Add(l.window).Sub(c.now).Seconds()) + 1 + if setHeaders { + remaining := l.n - count + if remaining < 0 { + remaining = 0 + } + c.headers.Set("RateLimit-Limit", strconv.Itoa(l.n)) + c.headers.Set("RateLimit-Remaining", strconv.Itoa(remaining)) + c.headers.Set("RateLimit-Reset", strconv.Itoa(reset)) + } + if count > l.n { + return httpx.E(429, "rate_limited", "Too many requests. Retry after the window resets.").RetryAfter(reset) + } + return nil +} + +func (c *Call) rateLimitPublic() error { + l, ok := publicLimits[c.op.op] + if !ok { + l = perMinute(defaultPublicLimit) + } + return c.hit("ip:"+c.ip+":"+c.op.op, l, true) +} + +func (c *Call) rateLimitPrincipal() error { + key := c.p.ID + if c.p.IsUser() { + key = c.p.SessionID + } + if l, ok := principalLimits[c.op.op]; ok { + return c.hit(key+":"+c.op.op, l, true) + } + return c.hit(key, perMinute(defaultPrincipalLimit), true) +} + +// peek reads a bucket's count for the current window without counting +// this request (failure-only limits, such as MFA disable attempts). +func (c *Call) peek(bucket string, l limit) (int, error) { + if c.s.DisableRateLimits { + return 0, nil + } + window := c.now.Truncate(l.window) + rows, err := c.s.DB.Autocommit().Query(c.ctx, `select to_jsonb(count) from rate_limit_counters + where bucket = :b and window_start = :w::timestamptz`, db.Args{"b": bucket, "w": db.Time(window)}) + if err != nil || len(rows) == 0 { + return 0, err + } + n, _ := strconv.Atoi(strings.TrimSpace(string(rows[0]))) + return n, nil +} diff --git a/internal/core/resolve.go b/internal/core/resolve.go new file mode 100644 index 0000000..dc17d88 --- /dev/null +++ b/internal/core/resolve.go @@ -0,0 +1,343 @@ +package core + +import ( + "encoding/json" + "slices" + "time" + + "github.com/Adron/substratalapps.com/internal/access" + "github.com/Adron/substratalapps.com/internal/db" +) + +// pair is one (user, application) access question. +type pair struct{ User, App string } + +// resolved is everything Access Control knows about one pair. +type resolved struct { + UserStatus string + Res access.Resolution + Paths []access.Path + Allowed bool + TenantID string +} + +type pathRow struct { + EntitlementID string `json:"entitlement_id"` + Source string `json:"source"` + Status string `json:"status"` + OrganizationID *string `json:"organization_id"` + StartsAt time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at"` + MemberScope *string `json:"member_scope"` + MemberOverrides []string `json:"member_overrides"` + CreatedAt time.Time `json:"created_at"` + MembershipActive bool `json:"membership_active"` +} + +// resolvePairs runs resolved_entitlement_status for every pair in one +// query, then applies Starter seat limits where they can matter. +func (c *Call) resolvePairs(pairs []pair) (map[pair]resolved, error) { + out := make(map[pair]resolved, len(pairs)) + if len(pairs) == 0 { + return out, nil + } + users := make([]string, len(pairs)) + apps := make([]string, len(pairs)) + for i, p := range pairs { + users[i], apps[i] = p.User, p.App + } + rows, err := c.q.Query(c.ctx, `with req as ( + select t.u, t.a from unnest(:users::text[], :apps::text[]) as t(u, a)) + select jsonb_build_object( + 'user_id', req.u, 'application_id', req.a, + 'user_status', (select status from users where id = req.u), + 'tenant_id', (select tenant_id from applications where id = req.a), + 'plan', (select t.plan from tenants t join applications a on a.tenant_id = t.id where a.id = req.a), + 'paths', coalesce((select jsonb_agg(p) from ( + select e.id as entitlement_id, e.source, e.status, null::text as organization_id, + e.starts_at, e.ends_at, null::text as member_scope, null::text[] as member_overrides, + e.created_at, true as membership_active + from entitlements e where e.user_id = req.u and e.application_id = req.a + union all + select e.id, e.source, e.status, e.organization_id, e.starts_at, e.ends_at, + e.member_scope, e.member_overrides, e.created_at, + m.status = 'active' + from entitlements e + join organization_memberships m on m.organization_id = e.organization_id and m.user_id = req.u + where e.application_id = req.a) p), '[]'::jsonb)) + from req`, db.Args{"users": db.TextArray(users), "apps": db.TextArray(apps)}) + if err != nil { + return nil, err + } + seatCache := map[string]*seatSet{} + for _, raw := range rows { + var r struct { + UserID string `json:"user_id"` + AppID string `json:"application_id"` + UserStatus *string `json:"user_status"` + TenantID *string `json:"tenant_id"` + Plan *string `json:"plan"` + Paths []pathRow `json:"paths"` + } + if err := json.Unmarshal(raw, &r); err != nil { + return nil, err + } + paths := make([]access.Path, 0, len(r.Paths)) + for _, p := range r.Paths { + paths = append(paths, access.Path{ + EntitlementID: p.EntitlementID, Source: p.Source, Status: p.Status, + OrganizationID: p.OrganizationID, StartsAt: p.StartsAt, EndsAt: p.EndsAt, + MemberScope: p.MemberScope, MemberOverrides: p.MemberOverrides, CreatedAt: p.CreatedAt, + MembershipActive: p.MembershipActive, + }) + } + // Seat limits apply only to org paths on a Starter Tenant, and only + // in live mode: test-mode Users are never seats (Pricing → What + // "seat" means), so live seat counts never gate test traffic. + if r.Plan != nil && *r.Plan == "starter" && r.TenantID != nil && !c.testMode() && hasOrgPath(paths) { + ss, ok := seatCache[*r.TenantID] + if !ok { + if ss, err = c.seats(*r.TenantID); err != nil { + return nil, err + } + seatCache[*r.TenantID] = ss + } + if ss.atCap() && !ss.isSeat(r.UserID) { + for i := range paths { + if paths[i].IsOrg() { + paths[i].SeatLimited = true + } + } + } + } + res := access.Resolve(r.UserID, paths, c.now) + status := "" + if r.UserStatus != nil { + status = *r.UserStatus + } + out[pair{r.UserID, r.AppID}] = resolved{ + UserStatus: status, Res: res, Paths: paths, Allowed: access.Allowed(status, res), TenantID: deref(r.TenantID), + } + } + return out, nil +} + +func (c *Call) resolveOne(userID, appID string) (resolved, error) { + m, err := c.resolvePairs([]pair{{userID, appID}}) + if err != nil { + return resolved{}, err + } + return m[pair{userID, appID}], nil +} + +func hasOrgPath(paths []access.Path) bool { + for _, p := range paths { + if p.IsOrg() { + return true + } + } + return false +} + +// seatSet is a Starter Tenant's seat occupancy (Pricing → What "seat" +// means): personal-path Users are always seats; org-only Users fill the +// remaining capacity in membership order. +type seatSet struct { + cap int + personal []string + org []string // org-path-only users, by (joined_at, user_id) +} + +func (s *seatSet) count() int { + n := len(s.personal) + len(s.org) + if n > s.cap { + n = max(s.cap, len(s.personal)) + } + return n +} + +func (s *seatSet) atCap() bool { return len(s.personal)+len(s.org) >= s.cap } + +func (s *seatSet) isSeat(userID string) bool { + if slices.Contains(s.personal, userID) { + return true + } + room := s.cap - len(s.personal) + for i, u := range s.org { + if i >= room { + return false + } + if u == userID { + return true + } + } + return false +} + +// seats computes a Tenant's seat occupancy from existing Entitlement and +// membership rows, with no separate tracking primitive. +func (c *Call) seats(tenantID string) (*seatSet, error) { + var r struct { + Personal []string `json:"personal"` + Org []string `json:"org"` + } + err := db.One(c.ctx, c.q, &r, seatsSQL, db.Args{"t": tenantID}) + if err != nil { + return nil, err + } + cap := 1 << 30 + if t, err := c.loadTenant(tenantID); err == nil { + if l := plans[t.Plan].Seats; l != nil { + cap = *l + } + } + return &seatSet{cap: cap, personal: r.Personal, org: r.Org}, nil +} + +// seatsSQL lists a Tenant's seat holders: live, active Users with an +// active personal path, then org-only members included by an active org +// grant (an Organization's suspension doesn't change its members' access). +const seatsSQL = `with apps as (select id from applications where tenant_id = :t), + grants as ( + select e.* from entitlements e + where e.application_id in (select id from apps) and e.status = 'active' and not e.test_mode + and e.starts_at <= now() and (e.ends_at is null or e.ends_at > now())), + personal as ( + select distinct g.user_id as u from grants g join users u on u.id = g.user_id + where g.user_id is not null and u.status = 'active' and not u.test_mode), + orgm as ( + select m.user_id as u, min(m.joined_at) as j from grants g + join organization_memberships m on m.organization_id = g.organization_id and m.status = 'active' + join users u on u.id = m.user_id and u.status = 'active' and not u.test_mode + where g.organization_id is not null + and (coalesce(g.member_scope, 'all_members') = 'all_members' + or (g.member_scope = 'allowlist' and m.user_id = any(g.member_overrides)) + or (g.member_scope = 'denylist' and not (m.user_id = any(coalesce(g.member_overrides, '{}'))))) + group by m.user_id) + select jsonb_build_object( + 'personal', coalesce((select jsonb_agg(u order by u) from personal), '[]'::jsonb), + 'org', coalesce((select jsonb_agg(u order by j, u) from orgm where u not in (select u from personal)), '[]'::jsonb))` + +// seatCount is the number of seats a Tenant currently uses. +func (c *Call) seatCount(tenantID string) (int, error) { + ss, err := c.seats(tenantID) + if err != nil { + return 0, err + } + return ss.count(), nil +} + +// appRolesFor loads the app-scoped Roles a User holds for an Application: +// explicit assignments plus the implicit default_app_role. +func (c *Call) appRolesFor(userID, appID string) ([]access.AppRole, error) { + type role struct { + ID string `json:"id"` + Permissions []string `json:"permissions"` + } + rs, err := db.All[role](c.ctx, c.q, `select jsonb_build_object('id', r.id, 'permissions', r.permissions) + from roles r where r.scope = :app and ( + r.id in (select role_id from user_role_assignments where user_id = :u and application_id = :app) + or r.name = (select default_app_role from applications where id = :app)) + order by r.id`, db.Args{"u": userID, "app": appID}) + if err != nil { + return nil, err + } + out := make([]access.AppRole, len(rs)) + for i, r := range rs { + out[i] = access.AppRole{ID: r.ID, Permissions: r.Permissions} + } + return out, nil +} + +// ── Derived access events (Webhooks → Event types) ───────────────────── + +// accessWatch snapshots resolved access for pairs before a mutation; flip +// compares after it and emits access.granted / access.revoked for every +// pair whose resolved access actually changed. +type accessWatch struct { + pairs []pair + before map[pair]resolved +} + +func (c *Call) watchAccess(pairs []pair) (*accessWatch, error) { + before, err := c.resolvePairs(pairs) + if err != nil { + return nil, err + } + return &accessWatch{pairs: pairs, before: before}, nil +} + +// flip emits an access.* event for each pair that changed, with reason +// for grants (granted) and revocations (revoked). +func (c *Call) flip(w *accessWatch, grantedReason, revokedReason string) error { + after, err := c.resolvePairs(w.pairs) + if err != nil { + return err + } + for _, p := range w.pairs { + b, a := w.before[p].Allowed, after[p].Allowed + if b == a { + continue + } + app := p.App + if a { + err = c.emit("access.granted", &app, map[string]any{ + "user_id": p.User, "application_id": p.App, "reason": grantedReason, "entitlement_status": "active"}) + } else { + err = c.emit("access.revoked", &app, map[string]any{ + "user_id": p.User, "application_id": p.App, "reason": revokedReason, + "entitlement_status": after[p].Res.Status}) + } + if err != nil { + return err + } + } + return nil +} + +// orgMemberPairs is every member (active or pending) of an Organization × +// one Application: the users an org grant change can affect. +func (c *Call) orgMemberPairs(orgID, appID string) ([]pair, error) { + us, err := db.All[string](c.ctx, c.q, `select to_jsonb(user_id) from organization_memberships + where organization_id = :o order by user_id`, db.Args{"o": orgID}) + if err != nil { + return nil, err + } + out := make([]pair, len(us)) + for i, u := range us { + out[i] = pair{u, appID} + } + return out, nil +} + +// userAppPairs is one User × every Application they have any path to. +func (c *Call) userAppPairs(userID string) ([]pair, error) { + as, err := db.All[string](c.ctx, c.q, `select to_jsonb(a) from ( + select application_id as a from entitlements where user_id = :u + union + select e.application_id from entitlements e + join organization_memberships m on m.organization_id = e.organization_id + where m.user_id = :u) x order by a`, db.Args{"u": userID}) + if err != nil { + return nil, err + } + out := make([]pair, len(as)) + for i, a := range as { + out[i] = pair{userID, a} + } + return out, nil +} + +// orgAppPairs is one User × every Application an Organization holds a grant to. +func (c *Call) orgAppPairs(userID, orgID string) ([]pair, error) { + as, err := db.All[string](c.ctx, c.q, `select to_jsonb(a) from (select distinct application_id as a from entitlements + where organization_id = :o) x order by a`, db.Args{"o": orgID}) + if err != nil { + return nil, err + } + out := make([]pair, len(as)) + for i, a := range as { + out[i] = pair{userID, a} + } + return out, nil +} diff --git a/internal/core/roles.go b/internal/core/roles.go new file mode 100644 index 0000000..3123496 --- /dev/null +++ b/internal/core/roles.go @@ -0,0 +1,753 @@ +package core + +import ( + "encoding/json" + "net/http" + "regexp" + "slices" + "sort" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/access" + "github.com/Adron/substratalapps.com/internal/api/gen" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" +) + +// PlatformPermission is one built-in platform permission key. +type PlatformPermission struct { + Key string `json:"key"` + Scope string `json:"scope"` + Description string `json:"description"` +} + +// PlatformPermissions is the platform permission catalog (Domain Model → +// Roles & Permissions → Platform permission catalog). +var PlatformPermissions = []PlatformPermission{ + {"users.list", "platform", "List/search User accounts. On an app-scoped key, confined to users with an access path to that Application."}, + {"users.manage", "platform", "Create, update, suspend, delete User accounts."}, + {"entitlements.manage", "platform", "Grant, toggle, and revoke Entitlements for any user. On an app-scoped key, confined to that Application."}, + {"applications.manage", "platform", "Create and edit Application catalog entries."}, + {"roles.manage", "platform", "Define Roles and assign/remove them on any user. On an app-scoped key, confined to that Application's Roles."}, + {"organizations.manage", "platform", "Manage any Organization: suspend/reactivate, membership, and member scope."}, + {"billing.manage", "platform", "View any Tenant's platform subscription and usage, and open its billing portal."}, + {"billing.refund", "platform", "Issue platform-subscription refunds and credits."}, + {"audit.view", "platform", "Query the Audit log for any user. On an app-scoped key, confined to that Application's events."}, + {"webhooks.manage", "platform", "Manage any webhook subscription, and create platform-scoped ones."}, + {"api_keys.manage", "platform", "Create, rotate, and revoke any API Key."}, + {"tenants.manage", "platform", "View any Tenant and request a tier change on a customer's behalf."}, +} + +func isPlatformPermission(k string) bool { + for _, p := range PlatformPermissions { + if p.Key == k { + return true + } + } + return false +} + +// appConfinable are the four platform permissions an app-scoped key may +// carry in confined form (API Keys → App-confined permissions). +var appConfinable = []string{"entitlements.manage", "roles.manage", "users.list", "audit.view"} + +// ── GET /v1/permissions ──────────────────────────────────────────────── + +func (s *Server) PermissionsList(w http.ResponseWriter, r *http.Request, params gen.PermissionsListParams) { + s.serve(w, r, opts{op: "permissions.list"}, func(c *Call) error { + out := []PlatformPermission{} + scope := deref(params.Scope) + if scope == "" || scope == "platform" { + out = append(out, PlatformPermissions...) + } + if scope != "platform" { + where, args := "true", db.Args{} + if app, ok := c.p.AppKey(); ok { + where, args = "a.id = :app", db.Args{"app": app} + } + if scope != "" { + where = "(" + where + ") and a.id = :scope" + args["scope"] = scope + } + apps, err := db.All[appRow](c.ctx, c.q, `select to_jsonb(a) from applications a where `+where+` order by a.id`, args) + if err != nil { + return err + } + for _, a := range apps { + if _, isKey := c.p.AppKey(); !isKey { + if ok, err := c.appVisible(a); err != nil { + return err + } else if !ok { + continue + } + } + for _, p := range a.Permissions { + out = append(out, PlatformPermission{Key: p.Key, Scope: a.ID, Description: deref(p.Description)}) + } + } + } + return c.OK(httpx.List[PlatformPermission]{Data: out}) + }) +} + +// ── Roles ────────────────────────────────────────────────────────────── + +type role struct { + ID string `json:"id"` + Name string `json:"name"` + Scope string `json:"scope"` + Description *string `json:"description"` + Permissions []string `json:"permissions"` + Seed bool `json:"seed"` + AssignmentCount int `json:"assignment_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + version int +} + +const roleSQL = `select to_jsonb(r) || jsonb_build_object('version_', r.version, + 'assignment_count', (select count(*) from user_role_assignments a where a.role_id = r.id)) from roles r` + +func (c *Call) queryRoles(where string, args db.Args) ([]role, error) { + raw, err := c.q.Query(c.ctx, roleSQL+" where "+where, args) + if err != nil { + return nil, err + } + out := make([]role, 0, len(raw)) + for _, x := range raw { + var r struct { + role + V int `json:"version_"` + } + if err := json.Unmarshal(x, &r); err != nil { + return nil, err + } + r.role.version = r.V + if r.role.Permissions == nil { + r.role.Permissions = []string{} + } + out = append(out, r.role) + } + return out, nil +} + +func (c *Call) loadRole(id string) (role, error) { + rs, err := c.queryRoles("r.id = :id", db.Args{"id": id}) + if err != nil { + return role{}, err + } + if len(rs) == 0 { + return role{}, httpx.NotFoundResource("role") + } + return rs[0], nil +} + +// roleID derives a Role id (Conventions → IDs). +func roleID(scope, name string) string { + if scope == "platform" { + return "role_platform_" + name + } + return "role_" + strings.TrimPrefix(scope, "app_") + "_" + name +} + +// canSeeRole: platform Roles to any User with a platform permission (and +// platform keys); app Roles to the app's owner, its own key, roles.manage. +func (c *Call) canSeeRole(r role) (bool, error) { + if r.Scope == "platform" { + return (c.p.IsUser() && (len(c.p.Perms) > 0)) || c.p.PlatformKey(), nil + } + if c.can("roles.manage") { + return true, nil + } + if app, ok := c.p.AppKey(); ok { + return app == r.Scope, nil + } + a, err := c.loadApp(r.Scope) + if err != nil { + return false, nil + } + return c.isAppOwner(a) +} + +// canManageRoleScope authorizes defining or changing Roles in scope. +func (c *Call) canManageRoleScope(scope string) (*appRow, error) { + if scope == "platform" { + if err := c.require("roles.manage"); err != nil { + return nil, err + } + return nil, nil + } + a, err := c.loadApp(scope) + if err == db.ErrNotFound { + return nil, httpx.NotFoundResource("application") + } + if err != nil { + return nil, err + } + if app, ok := c.p.AppKey(); ok && app != scope { + return nil, httpx.E(403, "role_scope_mismatch", "An app key can only act on its own Application's Roles.") + } + if ok, err := c.canManageApp("roles.manage", a); err != nil { + return nil, err + } else if !ok { + return nil, httpx.Forbidden("roles.manage") + } + return &a, nil +} + +var roleNameRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,40}$`) + +// validateRolePermissions: platform Roles hold only platform keys, app +// Roles only their Application's declared app..* keys; a platform +// Role can't grant what the caller doesn't hold. +func (c *Call) validateRolePermissions(scope string, a *appRow, perms []string) error { + for _, p := range perms { + if scope == "platform" { + if !isPlatformPermission(p) { + return httpx.E(422, "unknown_permission", "A platform Role can hold only platform permission keys.").With("permission", p) + } + if !c.can(p) { + return httpx.E(403, "role_escalation_forbidden", "You can't grant a permission you don't hold.").With("permission", p) + } + continue + } + declared := false + for _, ap := range a.Permissions { + if ap.Key == p { + declared = true + } + } + if !declared { + return httpx.E(422, "unknown_permission", "An app Role can hold only its Application's declared permission keys.").With("permission", p) + } + } + return nil +} + +func (s *Server) RolesList(w http.ResponseWriter, r *http.Request, params gen.RolesListParams) { + s.serve(w, r, opts{op: "roles.list"}, func(c *Call) error { + where, args := []string{"true"}, db.Args{} + if params.Scope != nil { + where = append(where, "r.scope = :scope") + args["scope"] = *params.Scope + } + lim := httpx.Limit(params.Limit) + filters := httpx.Filters(c.r) + pos, err := c.s.Cursors.Decode(deref(params.Cursor), filters) + if err != nil { + return err + } + if pos != nil { + where = append(where, "(r.created_at, r.id) < (:ps::timestamptz, :pi)") + args["ps"], args["pi"] = pos.Sort, pos.ID + } + all, err := c.queryRoles(strings.Join(where, " and ")+" order by r.created_at desc, r.id desc", args) + if err != nil { + return err + } + var visible []role + for _, x := range all { + if ok, err := c.canSeeRole(x); err != nil { + return err + } else if ok { + visible = append(visible, x) + } + if len(visible) > lim { + break + } + } + return c.OK(httpx.Paginate(c.s.Cursors, visible, lim, filters, func(x role) (string, string) { + return httpx.TimeKey(x.CreatedAt), x.ID + })) + }) +} + +func (s *Server) RolesGet(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "roles.get"}, func(c *Call) error { + x, err := c.loadRole(id) + if err != nil { + return err + } + if ok, err := c.canSeeRole(x); err != nil { + return err + } else if !ok { + return httpx.NotFoundResource("role") + } + c.Versioned(x.version) + return c.OK(x) + }) +} + +func (s *Server) RolesCreate(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "roles.create", idem: idemOptional}, func(c *Call) error { + var in struct { + Name string `json:"name"` + Scope string `json:"scope"` + Description *string `json:"description"` + Permissions []string `json:"permissions"` + } + if err := c.Decode(&in); err != nil { + return err + } + var f httpx.Fields + if !roleNameRe.MatchString(in.Name) { + f.Add("name", "invalid_format").Pattern = roleNameRe.String() + } + if in.Scope == "" { + f.Add("scope", "required") + } + if in.Permissions == nil { + f.Add("permissions", "required") + } + if in.Description != nil && len([]rune(*in.Description)) > 2000 { + f.Max("description", "too_long", 2000) + } + if err := f.Err(); err != nil { + return err + } + a, err := c.canManageRoleScope(in.Scope) + if err != nil { + return err + } + if a != nil { + if !slices.Contains(a.AvailableAppRoles, in.Name) { + return httpx.E(422, "app_role_not_declared", "An app Role's name must be one of the Application's available_app_roles.") + } + t, err := c.loadTenant(a.TenantID) + if err != nil { + return err + } + if err := tenantWritable(t); err != nil { + return err + } + if t.Restricted && !c.testMode() { + return subscriptionRequired(t) + } + } + perms := dedupe(in.Permissions) + if err := c.validateRolePermissions(in.Scope, a, perms); err != nil { + return err + } + id := roleID(in.Scope, in.Name) + if _, err := c.q.Exec(c.ctx, `insert into roles (id, name, scope, description, permissions, test_mode) + values (:id, :n, :s, :d, :p::text[], :tm)`, + db.Args{"id": id, "n": in.Name, "s": in.Scope, "d": in.Description, "p": db.TextArray(perms), "tm": c.testMode()}); err != nil { + if _, ok := db.UniqueViolation(err); ok { + return httpx.Conflict("role_name_taken", "A Role with this name already exists in this scope.") + } + return err + } + x, err := c.loadRole(id) + if err != nil { + return err + } + if err := c.audit(Audit{Action: "role.created", TargetType: "role", TargetID: id, ApplicationID: appIDOf(in.Scope), After: x}); err != nil { + return err + } + c.Versioned(x.version) + return c.Created(x) + }) +} + +func appIDOf(scope string) *string { + if scope == "platform" { + return nil + } + return &scope +} + +func (s *Server) RolesUpdate(w http.ResponseWriter, r *http.Request, id string, params gen.RolesUpdateParams) { + s.serve(w, r, opts{op: "roles.update"}, func(c *Call) error { + x, err := c.loadRole(id) + if err != nil { + return err + } + if ok, err := c.canSeeRole(x); err != nil || !ok { + if err == nil { + err = httpx.NotFoundResource("role") + } + return err + } + a, err := c.canManageRoleScope(x.Scope) + if err != nil { + return err + } + if x.Seed { + return httpx.E(403, "seed_role_immutable", "The built-in platform Roles can't be changed.") + } + if err := httpx.CheckIfMatch(c.r, x.version); err != nil { + return err + } + p, err := c.Patch() + if err != nil { + return err + } + if err := p.RejectReadOnly("id", "name", "scope", "seed", "assignment_count", "created_at", "updated_at"); err != nil { + return err + } + var f httpx.Fields + p.RequireNonNull(&f, "permissions") + next := x + if p.Has("description") { + next.Description, _ = p.String("description", &f) + if next.Description != nil && len([]rune(*next.Description)) > 2000 { + f.Max("description", "too_long", 2000) + } + } + if p.Has("permissions") { + var perms []string + if p.Get("permissions", &perms, &f) { + next.Permissions = dedupe(perms) + } + } + if err := f.Err(); err != nil { + return err + } + if err := c.validateRolePermissions(x.Scope, a, next.Permissions); err != nil { + return err + } + shrinks := false + for _, old := range x.Permissions { + if !slices.Contains(next.Permissions, old) { + shrinks = true + } + } + if err := c.destructiveIf(shrinks); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update roles set description = :d, permissions = :p::text[] where id = :id`, + db.Args{"d": next.Description, "p": db.TextArray(next.Permissions), "id": x.ID}); err != nil { + return err + } + after, err := c.loadRole(x.ID) + if err != nil { + return err + } + if err := c.audit(Audit{Action: "role.updated", TargetType: "role", TargetID: x.ID, ApplicationID: appIDOf(x.Scope), + Before: x, After: after}); err != nil { + return err + } + c.Versioned(after.version) + return c.OK(after) + }) +} + +func (s *Server) RolesDelete(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "roles.delete"}, func(c *Call) error { + x, err := c.loadRole(id) + if err != nil { + return err + } + if ok, err := c.canSeeRole(x); err != nil || !ok { + if err == nil { + err = httpx.NotFoundResource("role") + } + return err + } + if _, err := c.canManageRoleScope(x.Scope); err != nil { + return err + } + if x.Seed { + return httpx.E(403, "seed_role_immutable", "The built-in platform Roles can't be deleted.") + } + if err := httpx.CheckIfMatch(c.r, x.version); err != nil { + return err + } + if x.AssignmentCount > 0 { + return httpx.Conflict("role_in_use", "This Role still has assignments.") + } + if _, err := c.q.Exec(c.ctx, `delete from roles where id = :id`, db.Args{"id": x.ID}); err != nil { + return err + } + if err := c.audit(Audit{Action: "role.deleted", TargetType: "role", TargetID: x.ID, ApplicationID: appIDOf(x.Scope), Before: x}); err != nil { + return err + } + return c.NoContent() + }) +} + +// ── Role assignments ─────────────────────────────────────────────────── + +type actorRef struct { + Type string `json:"type"` + ID string `json:"id"` +} + +type roleAssignment struct { + UserID string `json:"user_id"` + RoleID string `json:"role_id"` + ApplicationID *string `json:"application_id"` + AssignedAt *time.Time `json:"assigned_at"` + AssignedBy *actorRef `json:"assigned_by"` + Implicit bool `json:"implicit"` +} + +func (c *Call) loadAssignments(userID string, appID *string) ([]roleAssignment, error) { + where, args := "a.user_id = :u", db.Args{"u": userID} + if appID != nil { + where += " and a.application_id = :app" + args["app"] = *appID + } + return db.All[roleAssignment](c.ctx, c.q, `select jsonb_build_object('user_id', a.user_id, 'role_id', a.role_id, + 'application_id', a.application_id, 'assigned_at', a.assigned_at, + 'assigned_by', jsonb_build_object('type', a.assigned_by_type, 'id', a.assigned_by_id), 'implicit', false) + from user_role_assignments a where `+where+` order by a.assigned_at, a.role_id`, args) +} + +func (s *Server) RolesListForUser(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe, params gen.RolesListForUserParams) { + s.serve(w, r, opts{op: "roles.listForUser"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + appFilter := params.ApplicationId + if app, ok := c.p.AppKey(); ok { + appFilter = &app + } else if !c.isSelf(uid) && !c.can("roles.manage") && !c.can("users.list") { + return httpx.Forbidden("roles.manage") + } + if _, err := c.visibleUser(uid); err != nil { + return err + } + rows, err := c.loadAssignments(uid, appFilter) + if err != nil { + return err + } + // Implicit default_app_role rows, for Applications the user can + // actively reach right now. + pairs, err := c.userAppPairs(uid) + if err != nil { + return err + } + res, err := c.resolvePairs(pairs) + if err != nil { + return err + } + for _, pr := range pairs { + if appFilter != nil && pr.App != *appFilter { + continue + } + if !res[pr].Allowed { + continue + } + a, err := c.loadApp(pr.App) + if err != nil || a.DefaultAppRole == nil { + continue + } + rid := roleID(a.ID, *a.DefaultAppRole) + explicit := false + for _, x := range rows { + if x.RoleID == rid { + explicit = true + } + } + if !explicit { + app := a.ID + rows = append(rows, roleAssignment{UserID: uid, RoleID: rid, ApplicationID: &app, Implicit: true}) + } + } + return c.OK(httpx.List[roleAssignment]{Data: rows}) + }) +} + +// authorizeAssignment applies "Who may assign what". +func (c *Call) authorizeAssignment(x role) error { + if x.Scope == "platform" { + if err := c.require("roles.manage"); err != nil { + return err + } + for _, p := range x.Permissions { + if !c.can(p) { + return httpx.E(403, "role_escalation_forbidden", "You can't assign a Role granting a permission you don't hold.").With("permission", p) + } + } + return nil + } + if app, ok := c.p.AppKey(); ok && app != x.Scope { + return httpx.E(403, "role_scope_mismatch", "An app key can only assign its own Application's Roles.") + } + _, err := c.canManageRoleScope(x.Scope) + return err +} + +func (s *Server) RolesAssign(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe, roleID string) { + s.serve(w, r, opts{op: "roles.assign", idem: idemOptional}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + x, err := c.loadRole(roleID) + if err != nil { + return err + } + if app, ok := c.p.AppKey(); ok && x.Scope != app { + return httpx.E(403, "role_scope_mismatch", "An app key can only assign its own Application's Roles.") + } + if err := c.authorizeAssignment(x); err != nil { + return err + } + u, err := c.visibleUser(uid) + if err != nil { + return err + } + if u.Status == "deleted" { + return httpx.NotFoundResource("user") + } + existing, err := c.loadAssignments(uid, nil) + if err != nil { + return err + } + for _, a := range existing { + if a.RoleID == x.ID { + return c.OK(a) + } + } + typ, actorID := c.actor() + app := appIDOf(x.Scope) + if _, err := c.q.Exec(c.ctx, `insert into user_role_assignments (user_id, role_id, application_id, + assigned_by_type, assigned_by_id, test_mode) + values (:u, :r, :a, :t, :by, :tm)`, + db.Args{"u": uid, "r": x.ID, "a": app, "t": typ, "by": actorID, "tm": c.testMode()}); err != nil { + return err + } + rows, err := c.loadAssignments(uid, nil) + if err != nil { + return err + } + var created roleAssignment + for _, a := range rows { + if a.RoleID == x.ID { + created = a + } + } + data := map[string]any{"user_id": uid, "role_id": x.ID, "application_id": app} + if err := c.audit(Audit{Action: "role.assigned", TargetType: "role_assignment", TargetID: uid + ":" + x.ID, + TargetUserID: &uid, ApplicationID: app, After: data}); err != nil { + return err + } + if err := c.emit("role.assigned", app, data); err != nil { + return err + } + return c.Created(created) + }) +} + +func (s *Server) RolesRemove(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe, roleID string) { + s.serve(w, r, opts{op: "roles.remove"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + x, err := c.loadRole(roleID) + if err != nil { + return err + } + if err := c.authorizeAssignment(x); err != nil { + return err + } + if x.ID == "role_platform_member" { + return httpx.Conflict("member_role_required", "Every User holds the member platform Role.") + } + n, err := c.q.Exec(c.ctx, `delete from user_role_assignments where user_id = :u and role_id = :r`, + db.Args{"u": uid, "r": x.ID}) + if err != nil { + return err + } + if n > 0 { + app := appIDOf(x.Scope) + data := map[string]any{"user_id": uid, "role_id": x.ID, "application_id": app} + if err := c.audit(Audit{Action: "role.removed", TargetType: "role_assignment", TargetID: uid + ":" + x.ID, + TargetUserID: &uid, ApplicationID: app, Before: data}); err != nil { + return err + } + if err := c.emit("role.removed", app, data); err != nil { + return err + } + } + return c.NoContent() + }) +} + +// ── GET /v1/users/{id}/apps/{appId}/effective-permissions ────────────── + +type accessPath struct { + Source string `json:"source"` + EntitlementID string `json:"entitlement_id"` + Status string `json:"status"` + OrganizationID *string `json:"organization_id"` + MemberDecision *string `json:"member_decision"` + StartsAt *time.Time `json:"starts_at,omitempty"` +} + +func (s *Server) PermissionsGetEffective(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe, appID gen.AppId) { + s.serve(w, r, opts{op: "permissions.getEffective"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + if app, ok := c.p.AppKey(); ok { + if app != appID { + return httpx.NotFoundResource("application") + } + } else if !c.isSelf(uid) && !c.can("users.list") && !c.can("entitlements.manage") { + return httpx.Forbidden("users.list") + } + if _, err := c.loadApp(appID); err == db.ErrNotFound { + return httpx.NotFoundResource("application") + } else if err != nil { + return err + } + u, err := c.visibleUser(uid) + if err != nil { + return err + } + res, err := c.resolveOne(uid, appID) + if err != nil { + return err + } + paths := make([]accessPath, 0, len(res.Paths)) + for _, p := range res.Paths { + ap := accessPath{Source: p.Source, EntitlementID: p.EntitlementID, Status: access.EffectiveStatus(p, c.now), + OrganizationID: p.OrganizationID} + if p.IsOrg() { + d := access.MemberDecision(p, uid) + if !p.MembershipActive { + d = access.Excluded + } + ap.MemberDecision = &d + } + if p.StartsAt.After(c.now) { + st := p.StartsAt + ap.StartsAt = &st + } + paths = append(paths, ap) + } + sort.SliceStable(paths, func(i, j int) bool { return paths[i].OrganizationID == nil && paths[j].OrganizationID != nil }) + // roles: explicit assignments for this Application always, plus the + // implicit default_app_role only while allowed. Permissions are + // empty unless allowed. + var roles, perms []string + if res.Allowed { + held, err := c.appRolesFor(uid, appID) + if err != nil { + return err + } + roles, perms = access.EffectivePermissions(true, held) + } else { + explicit, err := c.loadAssignments(uid, &appID) + if err != nil { + return err + } + roles, perms = []string{}, []string{} + for _, a := range explicit { + roles = append(roles, a.RoleID) + } + } + return c.OK(map[string]any{ + "user_id": uid, "application_id": appID, "allowed": res.Allowed, "user_status": u.Status, + "entitlement_status": res.Res.Status, "access_paths": paths, "roles": roles, + "effective_permissions": perms, "computed_at": c.now.Format(time.RFC3339), + }) + }) +} diff --git a/internal/core/server.go b/internal/core/server.go new file mode 100644 index 0000000..622dbac --- /dev/null +++ b/internal/core/server.go @@ -0,0 +1,220 @@ +// Package core implements every operation in docs/openapi.yaml. Server +// satisfies the generated gen.ServerInterface; each handler runs inside +// serve(), which gives it the request pipeline every endpoint shares: +// authentication, rate limiting, the destructive-operation gate, +// idempotency, one database transaction, and response buffering so the +// response is only written after that transaction commits. +package core + +import ( + "context" + "crypto/rsa" + "errors" + "log/slog" + "net/http" + "runtime/debug" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/api/gen" + "github.com/Adron/substratalapps.com/internal/auth" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/email" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/ids" +) + +// Notifier is told that a webhook outbox event committed, so delivery can +// start immediately instead of waiting for the next scheduled sweep (SQS +// in AWS, an in-process channel locally). +type Notifier interface { + EventCommitted(ctx context.Context, eventID string) +} + +// Billing is the Stripe-facing surface the billing endpoints need. +type Billing interface { + EnsureCustomer(ctx context.Context, tenantID, email string) (customerID string, err error) + CheckoutSession(ctx context.Context, in CheckoutInput) (url string, expires time.Time, err error) + PortalSession(ctx context.Context, customerID, returnURL string) (url string, expires time.Time, err error) +} + +// CheckoutInput is a Team upgrade request. +type CheckoutInput struct { + TenantID string + CustomerID string + Seats int + SuccessURL string + CancelURL string +} + +// Deps are everything Server needs from the outside world. +type Deps struct { + DB db.DB + Signer auth.Signer + Encrypter auth.Encrypter + Email email.Sender + Notifier Notifier + Billing Billing // nil disables checkout/portal (503 billing_unavailable) + Cursors httpx.Cursors + Log *slog.Logger + + Issuer string // JWT iss + PublicBaseURL string + AuthorizeURL string + DashboardURL string + CORSOrigins []string + Now func() time.Time + // DisableRateLimits turns the NFR rate limits off. Integration tests + // only, where every request comes from 127.0.0.1. + DisableRateLimits bool +} + +// Server implements gen.ServerInterface. +type Server struct { + Deps + keys map[string]*rsa.PublicKey +} + +var _ gen.ServerInterface = (*Server)(nil) + +// New builds a Server. +func New(d Deps) *Server { + if d.Now == nil { + d.Now = time.Now + } + if d.Log == nil { + d.Log = slog.Default() + } + if d.Notifier == nil { + d.Notifier = noopNotifier{} + } + return &Server{Deps: d, keys: d.Signer.PublicKeys()} +} + +type noopNotifier struct{} + +func (noopNotifier) EventCommitted(context.Context, string) {} + +// Handler returns the full HTTP surface: /v1 (generated routes), the +// well-known endpoints at the host root, and a JSON 404 for everything +// else. /mcp and /internal/stripe/webhook are separate Lambdas and mounted +// by the devserver alongside this handler. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + gen.HandlerWithOptions(s, gen.StdHTTPServerOptions{ + BaseURL: "/v1", + BaseRouter: mux, + ErrorHandlerFunc: s.paramError, + }) + mux.HandleFunc("GET /.well-known/jwks.json", s.jwks) + mux.HandleFunc("GET /.well-known/openid-configuration", s.openIDConfiguration) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + httpx.WriteError(w, httpx.ErrNotFound()) + }) + return s.middleware(mux) +} + +// middleware wraps every request: request id, panic recovery, CORS, +// security headers. +func (s *Server) middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rid := r.Header.Get("X-Request-Id") + if rid == "" || len(rid) > 128 { + rid = ids.New("req_") + } + r = r.WithContext(context.WithValue(r.Context(), requestIDKey{}, rid)) + w.Header().Set("X-Request-Id", rid) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "no-store") + if s.cors(w, r) { + return + } + defer func() { + if v := recover(); v != nil { + s.Log.Error("panic", "request_id", rid, "panic", v, "stack", string(debug.Stack())) + httpx.WriteError(w, httpx.ErrInternal()) + } + }() + next.ServeHTTP(w, r) + }) +} + +// cors allows the configured first-party origins on browser-facing GETs +// (NFR → CORS). It returns true when it fully handled a preflight. +func (s *Server) cors(w http.ResponseWriter, r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return false + } + allowed := false + for _, o := range s.CORSOrigins { + if o == origin { + allowed = true + } + } + if !allowed { + return false + } + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + w.Header().Set("Access-Control-Expose-Headers", "ETag, X-Request-Id, RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset") + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "GET, PATCH, POST, DELETE") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, Idempotency-Key, X-Request-Id") + w.Header().Set("Access-Control-Max-Age", "600") + w.WriteHeader(http.StatusNoContent) + return true + } + return false +} + +type requestIDKey struct{} + +// RequestID returns the request's X-Request-Id. +func RequestID(ctx context.Context) string { + v, _ := ctx.Value(requestIDKey{}).(string) + return v +} + +// paramError maps generated parameter-binding failures onto the spec's +// error codes. +func (s *Server) paramError(w http.ResponseWriter, r *http.Request, err error) { + var rh *gen.RequiredHeaderError + if errors.As(err, &rh) && strings.EqualFold(rh.ParamName, "Idempotency-Key") { + httpx.WriteError(w, httpx.ErrIdempotencyRequired()) + return + } + var ip *gen.InvalidParamFormatError + if errors.As(err, &ip) { + var f httpx.Fields + f.Add(ip.ParamName, "invalid_format") + httpx.WriteError(w, f.Err()) + return + } + httpx.WriteError(w, httpx.Invalid("%s", err.Error())) +} + +// HealthCheck is GET /v1/health: no auth, no database, always 200. +func (s *Server) HealthCheck(w http.ResponseWriter, r *http.Request) { + httpx.JSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) jwks(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=3600") + httpx.JSON(w, http.StatusOK, auth.JWKS(s.keys)) +} + +func (s *Server) openIDConfiguration(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=3600") + httpx.JSON(w, http.StatusOK, map[string]any{ + "issuer": s.Issuer, + "jwks_uri": s.Issuer + "/.well-known/jwks.json", + "authorization_endpoint": s.AuthorizeURL, + "token_endpoint": s.Issuer + "/v1/auth/oauth/token", + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"authorization_code", "refresh_token"}, + "code_challenge_methods_supported": []string{"S256"}, + "token_endpoint_auth_methods_supported": []string{"none"}, + "id_token_signing_alg_values_supported": []string{"RS256"}, + }) +} diff --git a/internal/core/system.go b/internal/core/system.go new file mode 100644 index 0000000..e83a367 --- /dev/null +++ b/internal/core/system.go @@ -0,0 +1,601 @@ +package core + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/schema" +) + +// system runs fn as the platform itself (actor "system") in one +// transaction pinned to testMode. Jobs that touch both modes call it once +// per mode, so every event and audit row carries the right mode. +func (s *Server) system(ctx context.Context, testMode bool, settings db.Settings, fn func(c *Call) error) error { + settings.TestMode = testMode + c := &Call{s: s, ctx: ctx, now: s.Now().UTC(), headers: map[string][]string{}, sysTestMode: testMode} + err := s.DB.Tx(ctx, settings, func(q db.Querier) error { + c.q = q + return fn(c) + }) + if err != nil { + return err + } + for _, f := range c.afterCommit { + f(context.WithoutCancel(ctx)) + } + return nil +} + +func eachMode(fn func(testMode bool) error) error { + for _, m := range []bool{false, true} { + if err := fn(m); err != nil { + return err + } + } + return nil +} + +// ── Stripe sync (DEPLOYMENT.md → Webhook handling) ───────────────────── + +// StripeSubscription is the subscription state the sync applies, read +// fresh from Stripe by the billing package. +type StripeSubscription struct { + ID string + TenantID string + Status string + Plan string // from the product's metadata.substratal_plan + PeriodStart *time.Time + PeriodEnd *time.Time + CancelAtPeriodEnd bool + SeatItemID string +} + +// RecordStripeEvent inserts the event id (insert-or-skip) and reports +// whether it's new. +func (s *Server) RecordStripeEvent(ctx context.Context, id, typ string) (bool, error) { + var fresh bool + err := s.system(ctx, false, db.Settings{}, func(c *Call) error { + n, err := c.q.Exec(ctx, `insert into stripe_events (id, type) values (:id, :t) on conflict (id) do nothing`, + db.Args{"id": id, "t": typ}) + fresh = n == 1 + return err + }) + return fresh, err +} + +// MarkStripeEventProcessed records a successfully handled event. +func (s *Server) MarkStripeEventProcessed(ctx context.Context, id, tenantID string) error { + return s.system(ctx, false, db.Settings{}, func(c *Call) error { + var t any + if tenantID != "" { + t = tenantID + } + _, err := c.q.Exec(ctx, `update stripe_events set processed_at = now(), + tenant_id = coalesce(tenant_id, (select id from tenants where id = :t)) where id = :id`, db.Args{"id": id, "t": t}) + return err + }) +} + +var lapsed = map[string]bool{"canceled": true, "unpaid": true, "incomplete_expired": true, "paused": true} + +// ApplyStripeSubscription is the subscription sync: Stripe is the only +// writer of plan after a Tenant is created. A lapse drops a user-owned +// Tenant that fits Starter back to Starter; anything else becomes +// restricted. No end user ever loses access over billing. +func (s *Server) ApplyStripeSubscription(ctx context.Context, sub StripeSubscription) error { + return s.system(ctx, false, db.Settings{}, func(c *Call) error { + var t tenantRow + err := db.One(ctx, c.q, &t, `select to_jsonb(t) from tenants t where id = :id or stripe_subscription_id = :sub + order by (id = :id) desc limit 1`, db.Args{"id": sub.TenantID, "sub": sub.ID}) + if err != nil { + return errf("stripe sync: no tenant for subscription %s: %w", sub.ID, err) + } + before := t + next := t + next.SubscriptionStatus = sub.Status + next.PeriodStart, next.PeriodEnd, next.CancelAtPeriodEnd = sub.PeriodStart, sub.PeriodEnd, sub.CancelAtPeriodEnd + subID := &sub.ID + switch { + case lapsed[sub.Status]: + u, err := c.tenantUsage(t) + if err != nil { + return err + } + fitsStarter := false + for _, p := range fitsPlans(t, u) { + fitsStarter = fitsStarter || p == "starter" + } + if t.OwnerType == "user" && fitsStarter && t.Tier == "shared" { + next.Plan, next.Restricted, subID = "starter", false, nil + next.SubscriptionStatus = "canceled" + } else { + next.Restricted = true + } + default: + if sub.Plan == "team" || sub.Plan == "enterprise" { + next.Plan = sub.Plan + } + if sub.Status == "active" || sub.Status == "trialing" || sub.Status == "past_due" { + next.Restricted = false + } + } + if _, err := c.q.Exec(ctx, `update tenants set plan = :plan, subscription_status = :st, stripe_subscription_id = :sub, + current_period_start = :ps::timestamptz, current_period_end = :pe::timestamptz, + cancel_at_period_end = :cap, restricted = :r where id = :id`, + db.Args{"plan": next.Plan, "st": next.SubscriptionStatus, "sub": db.NullString(subID), + "ps": db.NullTime(next.PeriodStart), "pe": db.NullTime(next.PeriodEnd), "cap": next.CancelAtPeriodEnd, + "r": next.Restricted, "id": t.ID}); err != nil { + return err + } + if before.Plan != next.Plan { + if err := c.audit(Audit{Action: "tenant.plan_changed", TargetType: "tenant", TargetID: t.ID, TenantID: &t.ID, + Before: map[string]any{"plan": before.Plan}, After: map[string]any{"plan": next.Plan}}); err != nil { + return err + } + } + if before.SubscriptionStatus != next.SubscriptionStatus || before.Restricted != next.Restricted { + if err := c.audit(Audit{Action: "tenant.subscription_status_changed", TargetType: "tenant", TargetID: t.ID, + TenantID: &t.ID, Before: map[string]any{"subscription_status": before.SubscriptionStatus, "restricted": before.Restricted}, + After: map[string]any{"subscription_status": next.SubscriptionStatus, "restricted": next.Restricted}}); err != nil { + return err + } + } + return nil + }) +} + +// UnprocessedStripeEvents lists recorded Stripe events still unprocessed +// five minutes after arrival, for the retry job. +func (s *Server) UnprocessedStripeEvents(ctx context.Context) ([]string, error) { + var out []string + err := s.system(ctx, false, db.Settings{}, func(c *Call) error { + var err error + out, err = db.All[string](ctx, c.q, `select to_jsonb(id) from stripe_events where processed_at is null + and received_at < now() - interval '5 minutes' order by received_at limit 100`, nil) + return err + }) + return out, err +} + +// SeatTarget is one paid Tenant's seat count for the daily seat sync. +type SeatTarget struct { + TenantID string + SubscriptionID string + Seats int + LastSynced *int +} + +// PaidTenantSeats lists every Team/Enterprise Tenant's current seat count. +func (s *Server) PaidTenantSeats(ctx context.Context) ([]SeatTarget, error) { + var out []SeatTarget + err := s.system(ctx, false, db.Settings{}, func(c *Call) error { + ts, err := db.All[tenantRow](ctx, c.q, `select to_jsonb(t) from tenants t + where plan in ('team','enterprise') and stripe_subscription_id is not null`, nil) + if err != nil { + return err + } + for _, t := range ts { + n, err := c.seatCount(t.ID) + if err != nil { + return err + } + out = append(out, SeatTarget{TenantID: t.ID, SubscriptionID: deref(t.StripeSubID), Seats: n, LastSynced: t.SeatCountSynced}) + } + return nil + }) + return out, err +} + +// RecordSeatSync stores the quantity last written to Stripe. +func (s *Server) RecordSeatSync(ctx context.Context, tenantID string, seats int) error { + return s.system(ctx, false, db.Settings{}, func(c *Call) error { + _, err := c.q.Exec(ctx, `update tenants set seat_count_synced = :n, seat_count_synced_at = now() where id = :t`, + db.Args{"n": int64(seats), "t": tenantID}) + return err + }) +} + +// TenantsMissingCustomer lists Tenants whose Stripe Customer creation failed. +func (s *Server) TenantsMissingCustomer(ctx context.Context) ([]string, error) { + var out []string + err := s.system(ctx, false, db.Settings{}, func(c *Call) error { + var err error + out, err = db.All[string](ctx, c.q, `select to_jsonb(id) from tenants where stripe_customer_id is null + and created_at < now() - interval '1 minute' order by created_at limit 100`, nil) + return err + }) + return out, err +} + +// ProvisionStripeCustomer is the retry path for one Tenant. +func (s *Server) ProvisionStripeCustomer(ctx context.Context, tenantID string) { + s.provisionStripeCustomer(ctx, tenantID) +} + +// ── Entitlement sweep (every 5 minutes) ──────────────────────────────── + +// SweepEntitlements expires rows past ends_at and fires access.granted +// for future-dated grants whose starts_at has arrived. Resolution already +// treats both as effective immediately; this makes the stored status and +// the events catch up. +func (s *Server) SweepEntitlements(ctx context.Context) (expired, started int, err error) { + err = eachMode(func(tm bool) error { + return s.system(ctx, tm, db.Settings{}, func(c *Call) error { + rows, err := c.queryEnts(`e.status in ('active','disabled') and e.ends_at <= now() + order by e.ends_at limit 500 for update skip locked`, db.Args{}) + if err != nil { + return err + } + for _, e := range rows { + pairs, err := c.affectedPairs(e) + if err != nil { + return err + } + // Resolution already treats this row as expired, so "before" + // is resolved as of the instant before ends_at. + now := c.now + c.now = e.EndsAt.Add(-time.Nanosecond) + watch, err := c.watchAccess(pairs) + c.now = now + if err != nil { + return err + } + if _, err := c.q.Exec(ctx, `update entitlements set status = 'expired' where id = :id`, db.Args{"id": e.ID}); err != nil { + return err + } + after, err := c.loadEnt(e.ID) + if err != nil { + return err + } + if err := c.entitlementEffects("entitlement.expired", &e, after, nil); err != nil { + return err + } + reason := "entitlement_expired" + if e.OrganizationID != nil { + reason = "org_grant_changed" + } + if e.Status == "active" { + if err := c.flip(watch, "entitlement_enabled", reason); err != nil { + return err + } + } + expired++ + } + starts, err := c.queryEnts(`e.status = 'active' and e.starts_at <= now() and e.start_notified_at is null + and e.starts_at > e.created_at order by e.starts_at limit 500 for update skip locked`, db.Args{}) + if err != nil { + return err + } + for _, e := range starts { + pairs, err := c.affectedPairs(e) + if err != nil { + return err + } + res, err := c.resolvePairs(pairs) + if err != nil { + return err + } + for _, p := range pairs { + r := res[p] + if r.Allowed && r.Res.ActiveVia != nil && r.Res.ActiveVia.EntitlementID == e.ID { + app := p.App + if err := c.emit("access.granted", &app, map[string]any{"user_id": p.User, "application_id": p.App, + "reason": "entitlement_started", "entitlement_status": "active"}); err != nil { + return err + } + } + } + if _, err := c.q.Exec(ctx, `update entitlements set start_notified_at = now() where id = :id`, db.Args{"id": e.ID}); err != nil { + return err + } + started++ + } + return nil + }) + }) + return +} + +// ── Erasure cascade (NFR → Hard-delete cascade) ──────────────────────── + +// RunErasures runs every scheduled erasure whose 7 days have passed. Each +// step is idempotent, and each step commits before the next, so a failed +// run resumes safely from the start. +func (s *Server) RunErasures(ctx context.Context) (int, error) { + done := 0 + err := eachMode(func(tm bool) error { + var due []string + if err := s.system(ctx, tm, db.Settings{}, func(c *Call) error { + var err error + due, err = db.All[string](ctx, c.q, `select to_jsonb(e.user_id) from erasure_requests e + join users u on u.id = e.user_id + where e.status = 'scheduled' and e.scheduled_for <= now() order by e.scheduled_for limit 50`, nil) + return err + }); err != nil { + return err + } + for _, uid := range due { + if err := s.erase(ctx, tm, uid); err != nil { + return errf("erasure %s: %w", uid, err) + } + done++ + } + return nil + }) + return done, err +} + +func (s *Server) erase(ctx context.Context, tm bool, uid string) error { + steps := []struct { + settings db.Settings + fn func(c *Call) error + }{ + {db.Settings{}, func(c *Call) error { // 1. credentials go, rows stay + if _, err := c.q.Exec(ctx, `delete from mfa_recovery_codes where user_identity_id in + (select id from user_identities where user_id = :u)`, db.Args{"u": uid}); err != nil { + return err + } + _, err := c.q.Exec(ctx, `update user_identities set password_hash = null, mfa_secret = null, mfa_pending_secret = null, + external_subject_id = null, mfa_enabled = false where user_id = :u`, db.Args{"u": uid}) + return err + }}, + {db.Settings{}, func(c *Call) error { // 2. profile, sessions, tokens + for _, q := range []string{ + `delete from profiles where user_id = :u`, + `delete from refresh_tokens where session_id in (select id from sessions where user_id = :u)`, + `delete from sessions where user_id = :u`, + `delete from auth_tokens where user_id = :u`, + } { + if _, err := c.q.Exec(ctx, q, db.Args{"u": uid}); err != nil { + return err + } + } + return nil + }}, + {db.Settings{}, func(c *Call) error { // 3. AppProfile fully, AppSettings x-pii keys + if _, err := c.q.Exec(ctx, `update app_profiles set display_handle = null, custom = '{}' where user_id = :u`, + db.Args{"u": uid}); err != nil { + return err + } + type row struct { + App string `json:"application_id"` + Schema json.RawMessage `json:"settings_schema"` + } + rows, err := db.All[row](ctx, c.q, `select jsonb_build_object('application_id', s.application_id, + 'settings_schema', a.settings_schema) from app_settings s join applications a on a.id = s.application_id + where s.user_id = :u`, db.Args{"u": uid}) + if err != nil { + return err + } + for _, r := range rows { + sch, _, _ := schema.Parse(r.Schema) + if pii := sch.PIIKeys(); len(pii) > 0 { + if _, err := c.q.Exec(ctx, `update app_settings set overrides = overrides - :keys::text[] + where user_id = :u and application_id = :a`, + db.Args{"keys": db.TextArray(pii), "u": uid, "a": r.App}); err != nil { + return err + } + } + } + return nil + }}, + {db.Settings{AuditMaintenance: true}, func(c *Call) error { // 4. redact snapshots, keep the shape + _, err := c.q.Exec(ctx, `update audit_events set before = null, after = null + where target_user_id = :u and (before is not null or after is not null)`, db.Args{"u": uid}) + return err + }}, + {db.Settings{}, func(c *Call) error { // 5. memberships, overrides, email + orgs, err := db.All[string](ctx, c.q, `select to_jsonb(organization_id) from organization_memberships + where user_id = :u and role = 'org_admin' and status = 'active'`, db.Args{"u": uid}) + if err != nil { + return err + } + if _, err := c.q.Exec(ctx, `delete from organization_memberships where user_id = :u`, db.Args{"u": uid}); err != nil { + return err + } + for _, org := range orgs { + // Never leave an Organization unmanageable: promote its + // longest-standing active member if it lost its last admin. + if _, err := c.q.Exec(ctx, `update organization_memberships set role = 'org_admin' + where organization_id = :o and user_id = (select user_id from organization_memberships + where organization_id = :o and status = 'active' order by joined_at, user_id limit 1) + and not exists (select 1 from organization_memberships where organization_id = :o + and role = 'org_admin' and status = 'active')`, db.Args{"o": org}); err != nil { + return err + } + } + if _, err := c.q.Exec(ctx, `update entitlements set member_overrides = array_remove(member_overrides, :u) + where :u = any(member_overrides)`, db.Args{"u": uid}); err != nil { + return err + } + _, err = c.q.Exec(ctx, `update users set email = 'erased+' || id || '@invalid.substratal', pending_email = null, + status = 'deleted', deleted_at = coalesce(deleted_at, now()) where id = :u`, db.Args{"u": uid}) + return err + }}, + // 6. Entitlement rows stay, pseudonymized by the steps above. + {db.Settings{}, func(c *Call) error { // 7. complete + if _, err := c.q.Exec(ctx, `update erasure_requests set status = 'completed', completed_at = now() + where user_id = :u and status = 'scheduled'`, db.Args{"u": uid}); err != nil { + return err + } + return c.audit(Audit{Action: "user.erased", TargetType: "user", TargetID: uid, TargetUserID: &uid, + ActorType: "system", ActorID: "system"}) + }}, + } + for _, st := range steps { + if err := s.system(ctx, tm, st.settings, st.fn); err != nil { + return err + } + } + return nil +} + +// ── Audit archival (NFR → Audit log lifecycle) ───────────────────────── + +// ArchivedEvent is the shape-only record kept in cold storage forever: +// every field but the before/after snapshot. +type ArchivedEvent struct { + ID string `json:"id"` + Action string `json:"action"` + ActorType string `json:"actor_type"` + ActorID string `json:"actor_id"` + TargetType string `json:"target_type"` + TargetID string `json:"target_id"` + TargetUserID *string `json:"target_user_id"` + ApplicationID *string `json:"application_id"` + OrganizationID *string `json:"organization_id"` + TenantID *string `json:"tenant_id"` + RequestID *string `json:"request_id"` + TestMode bool `json:"test_mode"` + Timestamp time.Time `json:"timestamp"` +} + +// Archiver writes a batch of shape-only events to cold storage (S3 Glacier +// Deep Archive in AWS; a local directory otherwise). +type Archiver func(ctx context.Context, batch []ArchivedEvent) error + +// ArchiveAudit moves events older than their Tenant's hot window to cold +// storage, shape only, and deletes them from the hot table. Events with no +// Tenant use the longest non-negotiated window (Team's 1 year); Enterprise +// windows are negotiated and kept until set per contract. +func (s *Server) ArchiveAudit(ctx context.Context, archive Archiver) (int, error) { + total := 0 + err := eachMode(func(tm bool) error { + for { + var batch []ArchivedEvent + err := s.system(ctx, tm, db.Settings{AuditMaintenance: true}, func(c *Call) error { + var err error + batch, err = db.All[ArchivedEvent](ctx, c.q, `select to_jsonb(a) - 'before' - 'after' from audit_events a + left join tenants t on t.id = a.tenant_id + where a.timestamp < now() - (case coalesce(t.plan, 'team') + when 'starter' then interval '30 days' when 'team' then interval '365 days' + else interval '100 years' end) + order by a.timestamp limit 1000 for update of a skip locked`, nil) + if err != nil || len(batch) == 0 { + return err + } + if err := archive(ctx, batch); err != nil { + return err + } + ids := make([]string, len(batch)) + for i, e := range batch { + ids[i] = e.ID + } + _, err = c.q.Exec(ctx, `delete from audit_events where id = any(:ids::text[])`, db.Args{"ids": db.TextArray(ids)}) + return err + }) + if err != nil { + return err + } + total += len(batch) + if len(batch) < 1000 { + return nil + } + } + }) + return total, err +} + +// ── Cleanup (nightly) ────────────────────────────────────────────────── + +// Cleanup removes expired bookkeeping rows and 30-day-old test data. +func (s *Server) Cleanup(ctx context.Context) error { + if err := s.system(ctx, false, db.Settings{AllModes: true}, func(c *Call) error { + for _, q := range []string{ + `delete from auth_tokens where expires_at < now() - interval '1 day'`, + `delete from idempotency_keys where expires_at < now()`, + `delete from rate_limit_counters where window_start < now() - interval '2 days'`, + `delete from refresh_tokens where session_id in (select id from sessions + where coalesce(revoked_at, least(idle_expires_at, absolute_expires_at)) < now() - interval '30 days')`, + `delete from sessions where coalesce(revoked_at, least(idle_expires_at, absolute_expires_at)) < now() - interval '30 days'`, + `delete from webhook_deliveries where created_at < now() - interval '30 days'`, + `delete from webhook_events e where created_at < now() - interval '30 days' + and not exists (select 1 from webhook_deliveries d where d.event_id = e.id)`, + `delete from stripe_events where received_at < now() - interval '90 days'`, + } { + if _, err := c.q.Exec(ctx, q, nil); err != nil { + return errf("cleanup %q: %w", strings.Fields(q)[2], err) + } + } + return nil + }); err != nil { + return err + } + return s.PurgeTestData(ctx) +} + +// PurgeTestData deletes test-mode rows older than 30 days (API Keys → +// Test vs. live), children first. +func (s *Server) PurgeTestData(ctx context.Context) error { + return s.system(ctx, true, db.Settings{AuditMaintenance: true}, func(c *Call) error { + old := `test_mode and created_at < now() - interval '30 days'` + users := `(select id from users where ` + old + `)` + for _, q := range []string{ + `delete from webhook_deliveries where ` + old, + `delete from webhook_events where ` + old, + `delete from webhook_subscriptions where ` + old, + `delete from audit_events where test_mode and timestamp < now() - interval '30 days'`, + `delete from app_settings where ` + old, + `delete from app_profiles where ` + old, + `delete from user_role_assignments where test_mode and assigned_at < now() - interval '30 days'`, + `delete from entitlements where ` + old, + `delete from organization_memberships where test_mode and invited_at < now() - interval '30 days'`, + `delete from roles where ` + old + ` and not exists (select 1 from user_role_assignments a where a.role_id = roles.id)`, + `delete from api_keys where ` + old, + `delete from mfa_recovery_codes where user_identity_id in (select id from user_identities where user_id in ` + users + `)`, + `delete from user_identities where user_id in ` + users, + `delete from refresh_tokens where session_id in (select id from sessions where user_id in ` + users + `)`, + `delete from sessions where user_id in ` + users, + `delete from auth_tokens where user_id in ` + users, + `delete from erasure_requests where user_id in ` + users, + `delete from profiles where user_id in ` + users, + `delete from settings where user_id in ` + users, + } { + if _, err := c.q.Exec(ctx, q, nil); err != nil { + return err + } + } + // Users and Organizations last, only once nothing references them. + for _, q := range []string{ + `delete from organizations o where ` + old + ` and not exists (select 1 from organization_memberships m where m.organization_id = o.id) + and not exists (select 1 from entitlements e where e.organization_id = o.id) + and not exists (select 1 from applications a where a.owner_organization_id = o.id) + and not exists (select 1 from tenants t where t.owner_organization_id = o.id)`, + `delete from users u where ` + old + ` and not exists (select 1 from organization_memberships m where m.user_id = u.id) + and not exists (select 1 from entitlements e where e.user_id = u.id) + and not exists (select 1 from applications a where a.owner_user_id = u.id) + and not exists (select 1 from tenants t where t.owner_user_id = u.id) + and not exists (select 1 from organizations o where o.created_by = u.id)`, + } { + if _, err := c.q.Exec(ctx, q, nil); err != nil { + return err + } + } + return nil + }) +} + +// RebuildAllProjections rebuilds every Application's typed settings view +// (nightly safety net for a rebuild that failed after a schema change). +func (s *Server) RebuildAllProjections(ctx context.Context) error { + var apps []string + if err := s.system(ctx, false, db.Settings{}, func(c *Call) error { + var err error + apps, err = db.All[string](ctx, c.q, `select to_jsonb(id) from applications order by id`, nil) + return err + }); err != nil { + return err + } + for _, a := range apps { + if err := RebuildSettingsProjection(ctx, s.DB, a); err != nil { + return err + } + } + return nil +} + +var _ = httpx.ErrInternal diff --git a/internal/core/tenants.go b/internal/core/tenants.go new file mode 100644 index 0000000..76a8564 --- /dev/null +++ b/internal/core/tenants.go @@ -0,0 +1,680 @@ +package core + +import ( + "context" + "encoding/json" + "net/http" + "slices" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/api/gen" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/ids" +) + +type tenant struct { + ID string `json:"id"` + OwnerType string `json:"owner_type"` + OwnerUserID *string `json:"owner_user_id"` + OwnerOrganizationID *string `json:"owner_organization_id"` + Tier string `json:"tier"` + Plan string `json:"plan"` + Region string `json:"region"` + Status string `json:"status"` + SubscriptionStatus string `json:"subscription_status"` + Restricted bool `json:"restricted"` + ApplicationCount int `json:"application_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (c *Call) tenantObject(t tenantRow) (tenant, error) { + var n int + if err := db.One(c.ctx, c.q, &n, `select to_jsonb(count(*)) from applications where tenant_id = :t`, db.Args{"t": t.ID}); err != nil { + return tenant{}, err + } + return tenant{ID: t.ID, OwnerType: t.OwnerType, OwnerUserID: t.OwnerUserID, OwnerOrganizationID: t.OwnerOrganizationID, + Tier: t.Tier, Plan: t.Plan, Region: t.Region, Status: t.Status, SubscriptionStatus: t.SubscriptionStatus, + Restricted: t.Restricted, ApplicationCount: n, CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt}, nil +} + +// visibleTenant: owners, tenants.manage, and (where extra is set) billing.manage. +func (c *Call) visibleTenant(id string, extra ...string) (tenantRow, bool, error) { + t, err := c.loadTenant(id) + if err != nil { + return t, false, err + } + owner, err := c.ownsTenant(t) + if err != nil { + return t, false, err + } + if owner || c.can("tenants.manage") { + return t, owner, nil + } + for _, p := range extra { + if c.can(p) { + return t, false, nil + } + } + return t, false, httpx.NotFoundResource("tenant") +} + +func (s *Server) TenantsList(w http.ResponseWriter, r *http.Request, params gen.TenantsListParams) { + s.serve(w, r, opts{op: "tenants.list"}, func(c *Call) error { + where, args := []string{"true"}, db.Args{} + if !c.can("tenants.manage") { + if !c.p.IsUser() { + return c.OK(httpx.List[tenant]{Data: []tenant{}}) + } + where = append(where, `(t.owner_user_id = :me or t.owner_organization_id in (select organization_id + from organization_memberships where user_id = :me and role = 'org_admin' and status = 'active'))`) + args["me"] = c.p.UserID + } else { + add := func(cond, key string, v *string) { + if v != nil { + where = append(where, cond) + args[key] = *v + } + } + add("t.plan = :plan", "plan", enumStr(params.Plan)) + add("t.tier = :tier", "tier", enumStr(params.Tier)) + add("t.status = :st", "st", enumStr(params.Status)) + add("t.owner_user_id = :ou", "ou", params.OwnerUserId) + add("t.owner_organization_id = :oo", "oo", params.OwnerOrganizationId) + if params.Restricted != nil { + where = append(where, "t.restricted = :rs") + args["rs"] = *params.Restricted + } + } + lim := httpx.Limit(params.Limit) + args["lim"] = int64(lim + 1) + filters := httpx.Filters(c.r) + pos, err := c.s.Cursors.Decode(deref(params.Cursor), filters) + if err != nil { + return err + } + if pos != nil { + where = append(where, "(t.created_at, t.id) < (:ps::timestamptz, :pi)") + args["ps"], args["pi"] = pos.Sort, pos.ID + } + rows, err := db.All[tenantRow](c.ctx, c.q, `select to_jsonb(t) from tenants t where `+strings.Join(where, " and ")+ + ` order by t.created_at desc, t.id desc limit :lim`, args) + if err != nil { + return err + } + out := make([]tenant, 0, len(rows)) + for _, t := range rows { + obj, err := c.tenantObject(t) + if err != nil { + return err + } + out = append(out, obj) + } + return c.OK(httpx.Paginate(c.s.Cursors, out, lim, filters, func(t tenant) (string, string) { + return httpx.TimeKey(t.CreatedAt), t.ID + })) + }) +} + +func (s *Server) TenantsGet(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "tenants.get"}, func(c *Call) error { + t, _, err := c.visibleTenant(id, "billing.manage") + if err != nil { + return err + } + obj, err := c.tenantObject(t) + if err != nil { + return err + } + return c.OK(obj) + }) +} + +// ── Tier-change requests ─────────────────────────────────────────────── + +type tierChange struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + FromTier string `json:"from_tier"` + FromRegion string `json:"from_region"` + RequestedTier string `json:"requested_tier"` + RequestedRegion *string `json:"requested_region"` + Status string `json:"status"` + Reason string `json:"reason"` + Notes *string `json:"notes"` + ScheduledFor *time.Time `json:"scheduled_for"` + RequestedBy string `json:"requested_by"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` + Version int `json:"-"` +} + +var supportedRegions = []string{"us-east-1", "us-west-2", "ca-central-1", "eu-west-1", "eu-central-1", "ap-southeast-2"} + +var tierRank = map[string]int{"shared": 0, "isolated": 1, "dedicated_region": 2} + +func (c *Call) loadTierChange(tenantID, id string) (tierChange, error) { + var x struct { + tierChange + V int `json:"version"` + } + err := db.One(c.ctx, c.q, &x, `select to_jsonb(r) from tier_change_requests r where id = :id and tenant_id = :t`, + db.Args{"id": id, "t": tenantID}) + if err == db.ErrNotFound { + return tierChange{}, httpx.NotFoundResource("tier_change_request") + } + x.tierChange.Version = x.V + return x.tierChange, err +} + +func (s *Server) TenantsListTierChangeRequests(w http.ResponseWriter, r *http.Request, id string, params gen.TenantsListTierChangeRequestsParams) { + s.serve(w, r, opts{op: "tenants.listTierChangeRequests"}, func(c *Call) error { + if _, _, err := c.visibleTenant(id); err != nil { + return err + } + where, args := []string{"r.tenant_id = :t"}, db.Args{"t": id} + lim := httpx.Limit(params.Limit) + args["lim"] = int64(lim + 1) + filters := httpx.Filters(c.r) + pos, err := c.s.Cursors.Decode(deref(params.Cursor), filters) + if err != nil { + return err + } + if pos != nil { + where = append(where, "(r.created_at, r.id) < (:ps::timestamptz, :pi)") + args["ps"], args["pi"] = pos.Sort, pos.ID + } + rows, err := db.All[tierChange](c.ctx, c.q, `select to_jsonb(r) from tier_change_requests r where `+ + strings.Join(where, " and ")+` order by r.created_at desc, r.id desc limit :lim`, args) + if err != nil { + return err + } + return c.OK(httpx.Paginate(c.s.Cursors, rows, lim, filters, func(x tierChange) (string, string) { + return httpx.TimeKey(x.CreatedAt), x.ID + })) + }) +} + +func (s *Server) TenantsGetTierChangeRequest(w http.ResponseWriter, r *http.Request, id string, requestID string) { + s.serve(w, r, opts{op: "tenants.getTierChangeRequest"}, func(c *Call) error { + if _, _, err := c.visibleTenant(id); err != nil { + return err + } + x, err := c.loadTierChange(id, requestID) + if err != nil { + return err + } + c.Versioned(x.Version) + return c.OK(x) + }) +} + +func (s *Server) TenantsRequestTierChange(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "tenants.requestTierChange", userOnly: true, idem: idemOptional}, func(c *Call) error { + if err := c.require("tenants.manage"); err != nil { + return err + } + t, err := c.loadTenant(id) + if err != nil { + return err + } + var in struct { + RequestedTier string `json:"requested_tier"` + RequestedRegion *string `json:"requested_region"` + Reason string `json:"reason"` + } + if err := c.Decode(&in); err != nil { + return err + } + var f httpx.Fields + if in.Reason == "" { + f.Add("reason", "required") + } else if len([]rune(in.Reason)) > 2000 { + f.Max("reason", "too_long", 2000) + } + if in.RequestedTier != "isolated" && in.RequestedTier != "dedicated_region" { + f.Enum("requested_tier", "isolated", "dedicated_region") + } + if err := f.Err(); err != nil { + return err + } + upgrade := tierRank[in.RequestedTier] > tierRank[t.Tier] || + (in.RequestedTier == "dedicated_region" && t.Tier == "dedicated_region" && in.RequestedRegion != nil && *in.RequestedRegion != t.Region) + if !upgrade { + return httpx.Conflict("invalid_tier_transition", "A tier change must move up the ladder shared < isolated < dedicated_region.") + } + if t.Plan != "enterprise" { + return httpx.Conflict("plan_does_not_allow_tier", "isolated and dedicated_region require the Enterprise plan.") + } + switch in.RequestedTier { + case "dedicated_region": + if in.RequestedRegion == nil || !slices.Contains(supportedRegions, *in.RequestedRegion) { + return httpx.E(422, "unsupported_region", "requested_region must be a supported region.").With("allowed", supportedRegions) + } + case "isolated": + if in.RequestedRegion != nil { + f.Add("requested_region", "unknown_value").Message = "not allowed for isolated" + return f.Err() + } + } + rid := ids.New(ids.TierChange) + if _, err := c.q.Exec(c.ctx, `insert into tier_change_requests (id, tenant_id, from_tier, from_region, requested_tier, + requested_region, reason, requested_by) + values (:id, :t, :ft, :fr, :rt, :rr, :reason, :by)`, + db.Args{"id": rid, "t": t.ID, "ft": t.Tier, "fr": t.Region, "rt": in.RequestedTier, "rr": in.RequestedRegion, + "reason": in.Reason, "by": c.p.UserID}); err != nil { + if _, ok := db.UniqueViolation(err); ok { + return httpx.Conflict("tier_change_already_pending", "This Tenant already has an open tier-change request.") + } + return err + } + x, err := c.loadTierChange(t.ID, rid) + if err != nil { + return err + } + if err := c.audit(Audit{Action: "tenant.tier_change_requested", TargetType: "tier_change_request", TargetID: rid, + TenantID: &t.ID, After: x}); err != nil { + return err + } + c.Versioned(x.Version) + return c.Created(x) + }) +} + +func (s *Server) TenantsUpdateTierChangeRequest(w http.ResponseWriter, r *http.Request, id string, requestID string, params gen.TenantsUpdateTierChangeRequestParams) { + s.serve(w, r, opts{op: "tenants.updateTierChangeRequest", userOnly: true}, func(c *Call) error { + if err := c.require("tenants.manage"); err != nil { + return err + } + t, err := c.loadTenant(id) + if err != nil { + return err + } + x, err := c.loadTierChange(id, requestID) + if err != nil { + return err + } + if err := httpx.CheckIfMatch(c.r, x.Version); err != nil { + return err + } + var in struct { + Status string `json:"status"` + ScheduledFor *time.Time `json:"scheduled_for"` + Notes *string `json:"notes"` + } + if err := c.Decode(&in); err != nil { + return err + } + if in.Notes != nil && len([]rune(*in.Notes)) > 2000 { + var f httpx.Fields + f.Max("notes", "too_long", 2000) + return f.Err() + } + bad := httpx.Conflict("invalid_request_transition", "The request status change from "+x.Status+" to "+in.Status+" isn't allowed.") + notes := x.Notes + if in.Notes != nil { + notes = in.Notes + } + needNotes := func() error { + if in.Notes == nil || *in.Notes == "" { + var f httpx.Fields + f.Add("notes", "required") + return f.Err() + } + return nil + } + set := map[string]string{"status": in.Status} + switch { + case x.Status == "pending" && in.Status == "scheduled": + if in.ScheduledFor == nil || !in.ScheduledFor.After(c.now) { + var f httpx.Fields + f.Add("scheduled_for", "required").Message = "must be in the future" + return f.Err() + } + if _, err := c.q.Exec(c.ctx, `update tier_change_requests set status = 'scheduled', scheduled_for = :sf::timestamptz, + notes = :n where id = :id`, db.Args{"sf": db.Time(*in.ScheduledFor), "n": notes, "id": x.ID}); err != nil { + return err + } + c.notifyTenantOwner(t, "Your Substratal maintenance window is scheduled", + "Your Tenant's tier change is scheduled for "+in.ScheduledFor.UTC().Format(time.RFC1123)+".") + case (x.Status == "pending" || x.Status == "scheduled") && in.Status == "in_progress": + if _, err := c.q.Exec(c.ctx, `update tier_change_requests set status = 'in_progress', started_at = now(), notes = :n + where id = :id`, db.Args{"n": notes, "id": x.ID}); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update tenants set status = 'migrating' where id = :t`, db.Args{"t": t.ID}); err != nil { + return err + } + case x.Status == "in_progress" && in.Status == "completed": + region := "us-east-1" + if x.RequestedRegion != nil { + region = *x.RequestedRegion + } + if _, err := c.q.Exec(c.ctx, `update tier_change_requests set status = 'completed', completed_at = now(), notes = :n + where id = :id`, db.Args{"n": notes, "id": x.ID}); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update tenants set tier = :tier, region = :region, status = 'active' where id = :t`, + db.Args{"tier": x.RequestedTier, "region": region, "t": t.ID}); err != nil { + return err + } + if err := c.audit(Audit{Action: "tenant.tier_changed", TargetType: "tenant", TargetID: t.ID, TenantID: &t.ID, + Before: map[string]any{"tier": t.Tier, "region": t.Region}, + After: map[string]any{"tier": x.RequestedTier, "region": region}}); err != nil { + return err + } + case (x.Status == "pending" || x.Status == "scheduled") && in.Status == "cancelled": + if err := needNotes(); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update tier_change_requests set status = 'cancelled', notes = :n where id = :id`, + db.Args{"n": notes, "id": x.ID}); err != nil { + return err + } + case x.Status == "in_progress" && in.Status == "cancelled": + if err := needNotes(); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update tier_change_requests set status = 'cancelled', notes = :n where id = :id`, + db.Args{"n": notes, "id": x.ID}); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update tenants set status = 'active' where id = :t`, db.Args{"t": t.ID}); err != nil { + return err + } + default: + return bad + } + after, err := c.loadTierChange(id, requestID) + if err != nil { + return err + } + _ = set + if err := c.audit(Audit{Action: "tenant.tier_change_request_updated", TargetType: "tier_change_request", TargetID: x.ID, + TenantID: &t.ID, Before: x, After: after}); err != nil { + return err + } + c.Versioned(after.Version) + return c.OK(after) + }) +} + +// notifyTenantOwner emails a Tenant's owner (or its Organization's admins). +func (c *Call) notifyTenantOwner(t tenantRow, subject, text string) { + var emails []string + if t.OwnerUserID != nil { + emails, _ = db.All[string](c.ctx, c.q, `select to_jsonb(email::text) from users where id = :u`, db.Args{"u": *t.OwnerUserID}) + } else { + emails, _ = db.All[string](c.ctx, c.q, `select to_jsonb(u.email::text) from organization_memberships m + join users u on u.id = m.user_id where m.organization_id = :o and m.role = 'org_admin' and m.status = 'active'`, + db.Args{"o": *t.OwnerOrganizationID}) + } + for _, e := range emails { + c.notice(e, "tenant_notice", subject, text) + } +} + +// ── Billing (API Reference → Billing) ────────────────────────────────── + +type usageLimit struct { + Limit *int `json:"limit"` + Current *int `json:"current,omitempty"` + Included *int `json:"included,omitempty"` +} + +type usageCounts struct { + Applications, Seats, AppRolesMax, Webhooks int +} + +func (c *Call) tenantUsage(t tenantRow) (usageCounts, error) { + var u usageCounts + var row struct { + Apps int `json:"apps"` + RolesMax int `json:"roles_max"` + Webhooks int `json:"webhooks"` + } + if err := db.One(c.ctx, c.q, &row, `select jsonb_build_object( + 'apps', (select count(*) from applications where tenant_id = :t), + 'roles_max', coalesce((select max(cardinality(available_app_roles)) from applications where tenant_id = :t), 0), + 'webhooks', (select count(*) from webhook_subscriptions w where w.tenant_id = :t))`, db.Args{"t": t.ID}); err != nil { + return u, err + } + seats, err := c.seatCount(t.ID) + if err != nil { + return u, err + } + return usageCounts{Applications: row.Apps, Seats: seats, AppRolesMax: row.RolesMax, Webhooks: row.Webhooks}, nil +} + +// fitsPlans lists the plans the Tenant's current usage would fit. +func fitsPlans(t tenantRow, u usageCounts) []string { + var out []string + for _, name := range []string{"starter", "team", "enterprise"} { + l := plans[name] + if name == "starter" && t.OwnerType != "user" { + continue + } + if name != "enterprise" && t.Tier != "shared" { + continue + } + fits := (l.Applications == nil || u.Applications <= *l.Applications) && + (l.AppRoles == nil || u.AppRolesMax <= *l.AppRoles) && + (l.Webhooks == nil || u.Webhooks <= *l.Webhooks) && + (l.Seats == nil || u.Seats <= *l.Seats) + if fits { + out = append(out, name) + } + } + return out +} + +func (s *Server) BillingGetUsage(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "billing.getUsage"}, func(c *Call) error { + t, _, err := c.visibleTenant(id, "billing.manage") + if err != nil { + return err + } + u, err := c.tenantUsage(t) + if err != nil { + return err + } + l := plans[t.Plan] + included := l.SeatsIncluded + return c.OK(map[string]any{ + "tenant_id": t.ID, "plan": t.Plan, + "limits": map[string]usageLimit{ + "applications": {Limit: l.Applications, Current: &u.Applications}, + "seats": {Limit: l.Seats, Current: &u.Seats, Included: &included}, + "app_roles": {Limit: l.AppRoles, Current: &u.AppRolesMax}, + "webhooks": {Limit: l.Webhooks, Current: &u.Webhooks}, + "audit_hot_window_days": {Limit: l.AuditHotDays}, + }, + "fits_plans": fitsPlans(t, u), + }) + }) +} + +func (s *Server) BillingGetSubscription(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "billing.getSubscription"}, func(c *Call) error { + t, owner, err := c.visibleTenant(id, "billing.manage") + if err != nil { + return err + } + if !owner && !c.can("billing.manage") { + return httpx.NotFoundResource("tenant") + } + seats, err := c.seatCount(t.ID) + if err != nil { + return err + } + included := plans[t.Plan].SeatsIncluded + billable := 0 + if t.Plan != "starter" && t.SeatCountSynced != nil && *t.SeatCountSynced > included { + billable = *t.SeatCountSynced - included + } + addOns := []map[string]any{} + switch { + case t.Plan == "enterprise" && t.Tier == "isolated": + addOns = append(addOns, map[string]any{"code": "isolated_tenancy", "amount": 75000}) + case t.Plan == "enterprise" && t.Tier == "dedicated_region": + addOns = append(addOns, map[string]any{"code": "dedicated_region_tenancy", "amount": 150000}) + } + return c.OK(map[string]any{ + "tenant_id": t.ID, "plan": t.Plan, "subscription_status": t.SubscriptionStatus, "restricted": t.Restricted, + "current_period_start": t.PeriodStart, "current_period_end": t.PeriodEnd, + "cancel_at_period_end": t.CancelAtPeriodEnd, + "seats": map[string]any{"current": seats, "included": included, "billable": billable, + "last_synced_at": t.SeatCountSyncedAt}, + "add_ons": addOns, "currency": "usd", + }) + }) +} + +func billingUnavailable() error { + return httpx.E(503, "billing_unavailable", "Billing is temporarily unavailable.").RetryAfter(30) +} + +func (s *Server) BillingCreateCheckoutSession(w http.ResponseWriter, r *http.Request, id string, params gen.BillingCreateCheckoutSessionParams) { + s.serve(w, r, opts{op: "billing.createCheckoutSession", userOnly: true, idem: idemRequired}, func(c *Call) error { + t, owner, err := c.visibleTenant(id, "billing.manage") + if err != nil { + return err + } + if !owner { + return httpx.Forbidden("") + } + var in struct { + Plan string `json:"plan"` + SuccessURL string `json:"success_url"` + CancelURL string `json:"cancel_url"` + } + if err := c.Decode(&in); err != nil { + return err + } + if in.Plan == "enterprise" { + return httpx.E(422, "plan_requires_sales", "Enterprise is arranged through sales.") + } + var f httpx.Fields + if in.Plan != "team" { + f.Enum("plan", "team") + } + if !httpsURL(in.SuccessURL) { + f.Add("success_url", "invalid_format").Allowed = []string{"https"} + } + if !httpsURL(in.CancelURL) { + f.Add("cancel_url", "invalid_format").Allowed = []string{"https"} + } + if err := f.Err(); err != nil { + return err + } + if !slices.Contains([]string{"none", "canceled", "incomplete_expired", "unpaid"}, t.SubscriptionStatus) { + return httpx.Conflict("subscription_exists", "This Tenant already has a subscription; use the billing portal.") + } + if c.s.Billing == nil { + return billingUnavailable() + } + owners, err := db.All[string](c.ctx, c.q, `select to_jsonb(email::text) from users where id = :u`, db.Args{"u": c.p.UserID}) + if err != nil { + return err + } + customer, err := c.s.ensureStripeCustomer(c.ctx, c, t, first(owners)) + if err != nil { + return billingUnavailable() + } + seats, err := c.seatCount(t.ID) + if err != nil { + return err + } + url, expires, err := c.s.Billing.CheckoutSession(c.ctx, CheckoutInput{TenantID: t.ID, CustomerID: customer, + Seats: seats, SuccessURL: in.SuccessURL, CancelURL: in.CancelURL}) + if err != nil { + c.s.Log.Error("stripe checkout failed", "tenant", t.ID, "err", err) + return billingUnavailable() + } + return c.Created(map[string]any{"url": url, "expires_at": expires.UTC().Format(time.RFC3339)}) + }) +} + +func (s *Server) BillingCreatePortalSession(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "billing.createPortalSession", userOnly: true}, func(c *Call) error { + t, owner, err := c.visibleTenant(id, "billing.manage") + if err != nil { + return err + } + if !owner && !c.can("billing.manage") { + return httpx.Forbidden("billing.manage") + } + var in struct { + ReturnURL string `json:"return_url"` + } + if err := c.Decode(&in); err != nil { + return err + } + if !httpsURL(in.ReturnURL) { + var f httpx.Fields + f.Add("return_url", "invalid_format").Allowed = []string{"https"} + return f.Err() + } + if t.StripeCustomerID == nil || (t.SubscriptionStatus == "none" && t.StripeSubID == nil) { + return httpx.Conflict("no_billing_account", "This Tenant has no billing history to manage yet.") + } + if c.s.Billing == nil { + return billingUnavailable() + } + url, expires, err := c.s.Billing.PortalSession(c.ctx, *t.StripeCustomerID, in.ReturnURL) + if err != nil { + c.s.Log.Error("stripe portal failed", "tenant", t.ID, "err", err) + return billingUnavailable() + } + return c.Created(map[string]any{"url": url, "expires_at": expires.UTC().Format(time.RFC3339)}) + }) +} + +func first(ss []string) string { + if len(ss) == 0 { + return "" + } + return ss[0] +} + +// ensureStripeCustomer returns the Tenant's Stripe Customer, creating it +// (and recording it) if the post-commit provisioning hasn't yet. +func (s *Server) ensureStripeCustomer(ctx context.Context, c *Call, t tenantRow, email string) (string, error) { + if t.StripeCustomerID != nil { + return *t.StripeCustomerID, nil + } + id, err := s.Billing.EnsureCustomer(ctx, t.ID, email) + if err != nil { + return "", err + } + _, err = c.q.Exec(ctx, `update tenants set stripe_customer_id = :c where id = :t and stripe_customer_id is null`, + db.Args{"c": id, "t": t.ID}) + return id, err +} + +// provisionStripeCustomer creates the Stripe Customer just after a new +// Tenant commits (DEPLOYMENT.md → Stripe Billing → Objects and mapping). +// A failure is left for the scheduled retry job to fill in. +func (s *Server) provisionStripeCustomer(ctx context.Context, tenantID string) { + if s.Billing == nil { + return + } + id, err := s.Billing.EnsureCustomer(ctx, tenantID, "") + if err != nil { + s.Log.Warn("stripe customer provisioning deferred to retry job", "tenant", tenantID, "err", err) + return + } + err = s.DB.Tx(ctx, db.Settings{AllModes: true}, func(q db.Querier) error { + _, err := q.Exec(ctx, `update tenants set stripe_customer_id = :c where id = :t and stripe_customer_id is null`, + db.Args{"c": id, "t": tenantID}) + return err + }) + if err != nil { + s.Log.Error("record stripe customer", "tenant", tenantID, "err", err) + } +} + +var _ = json.Marshal diff --git a/internal/core/users.go b/internal/core/users.go new file mode 100644 index 0000000..637bc2f --- /dev/null +++ b/internal/core/users.go @@ -0,0 +1,616 @@ +package core + +import ( + "net/http" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/api/gen" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" +) + +// canReadUser: self, users.list, or an app-confined users.list key for a +// User with an access path to its Application (anyone else 404s). +func (c *Call) canReadUser(u userRow) (bool, error) { + if c.isSelf(u.ID) || c.can("users.list") || c.can("users.manage") { + return true, nil + } + if app, ok := c.p.AppKey(); ok && c.p.Perms["users.list"] { + return c.userHasAccessPath(u.ID, app) + } + return false, nil +} + +func (s *Server) UsersList(w http.ResponseWriter, r *http.Request, params gen.UsersListParams) { + s.serve(w, r, opts{op: "users.list"}, func(c *Call) error { + appFilter := params.ApplicationId + if app, ok := c.p.AppKey(); ok { + if !c.p.Perms["users.list"] { + return httpx.Forbidden("users.list") + } + appFilter = &app // forced, whatever the caller passed + } else if err := c.require("users.list"); err != nil { + if c.require("users.manage") != nil { + return err + } + } + lim := httpx.Limit(params.Limit) + filters := httpx.Filters(c.r) + pos, err := c.s.Cursors.Decode(deref(params.Cursor), filters) + if err != nil { + return err + } + var where []string + args := db.Args{"lim": int64(lim + 1)} + if params.Status != nil { + st := string(*params.Status) + if st == "deleted" && !c.can("users.manage") { + return c.OK(httpx.List[userRow]{Data: []userRow{}}) + } + where = append(where, "u.status = :status") + args["status"] = st + } else { + where = append(where, "u.status <> 'deleted'") + } + if params.Email != nil { + where = append(where, "u.email = :email") + args["email"] = *params.Email + } + if params.Q != nil { + if len([]rune(*params.Q)) < 3 { + var f httpx.Fields + f.Add("q", "too_short").Min = ip(3) + return f.Err() + } + where = append(where, `(lower(u.email::text) like :q or exists (select 1 from profiles p + where p.user_id = u.id and lower(p.display_name) like :q))`) + args["q"] = strings.ToLower(escapeLike(*params.Q)) + "%" + } + if appFilter != nil { + where = append(where, `(exists (select 1 from entitlements e where e.user_id = u.id and e.application_id = :app) + or exists (select 1 from entitlements e join organization_memberships m on m.organization_id = e.organization_id + where m.user_id = u.id and e.application_id = :app))`) + args["app"] = *appFilter + } + if params.OrganizationId != nil { + where = append(where, `exists (select 1 from organization_memberships m where m.user_id = u.id and m.organization_id = :org)`) + args["org"] = *params.OrganizationId + } + if params.CreatedAfter != nil { + where = append(where, "u.created_at > :ca::timestamptz") + args["ca"] = db.Time(*params.CreatedAfter) + } + if params.CreatedBefore != nil { + where = append(where, "u.created_at < :cb::timestamptz") + args["cb"] = db.Time(*params.CreatedBefore) + } + if pos != nil { + where = append(where, "(u.created_at, u.id) < (:ps::timestamptz, :pi)") + args["ps"], args["pi"] = pos.Sort, pos.ID + } + // The system account is internal; it's never listed. + where = append(where, "u.id <> 'usr_01JAG0SYSTEM00000000000000'") + raw, err := c.q.Query(c.ctx, userRowSQL+" where "+strings.Join(where, " and ")+ + " order by u.created_at desc, u.id desc limit :lim", args) + if err != nil { + return err + } + rows := make([]userRow, 0, len(raw)) + for _, x := range raw { + u, err := decodeUser(x) + if err != nil { + return err + } + rows = append(rows, u) + } + return c.OK(httpx.Paginate(c.s.Cursors, rows, lim, filters, func(u userRow) (string, string) { + return httpx.TimeKey(u.CreatedAt), u.ID + })) + }) +} + +func escapeLike(s string) string { + return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s) +} + +func (s *Server) UsersCreate(w http.ResponseWriter, r *http.Request, params gen.UsersCreateParams) { + s.serve(w, r, opts{op: "users.create", idem: idemOptional}, func(c *Call) error { + if err := c.require("users.manage"); err != nil { + return err + } + var in struct { + Email string `json:"email"` + DisplayName *string `json:"display_name"` + Status *string `json:"status"` + SendInvitation *bool `json:"send_invitation"` + } + if err := c.Decode(&in); err != nil { + return err + } + var f httpx.Fields + addr, ok := validEmail(in.Email) + if in.Email == "" { + f.Add("email", "required") + } else if !ok { + f.Add("email", "invalid_format") + } + status := "invited" + if in.Status != nil { + status = *in.Status + if status != "invited" && status != "active" { + f.Enum("status", "invited", "active") + } + } + if in.DisplayName != nil && len([]rune(*in.DisplayName)) > 100 { + f.Max("display_name", "too_long", 100) + } + if err := f.Err(); err != nil { + return err + } + if _, taken, err := c.emailTaken(addr); err != nil { + return err + } else if taken { + return httpx.Conflict("email_taken", "A user with this email already exists.") + } + u, err := c.createUser(newUser{Email: addr, Status: status, DisplayName: deref(in.DisplayName)}) + if err != nil { + return err + } + if status == "invited" && (in.SendInvitation == nil || *in.SendInvitation) { + if err := c.sendInvitation(u.ID, u.Email, nil); err != nil { + return err + } + } + c.Versioned(u.Version) + return c.Created(u) + }) +} + +func (s *Server) UsersGet(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe) { + s.serve(w, r, opts{op: "users.get"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + u, err := c.visibleUser(uid) + if err != nil { + return err + } + if ok, err := c.canReadUser(u); err != nil { + return err + } else if !ok { + return httpx.NotFoundResource("user") + } + c.Versioned(u.Version) + return c.OK(u) + }) +} + +func (s *Server) UsersUpdate(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe, params gen.UsersUpdateParams) { + s.serve(w, r, opts{op: "users.update"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + admin := c.can("users.manage") + if !admin && !c.isSelf(uid) { + return httpx.Forbidden("users.manage") + } + u, err := c.visibleUser(uid) + if err != nil { + return err + } + if err := httpx.CheckIfMatch(c.r, u.Version); err != nil { + return err + } + p, err := c.Patch() + if err != nil { + return err + } + if err := p.RejectReadOnly("id", "email_verified", "pending_email", "mfa_enabled", "signup_application_id", + "test_mode", "created_at", "updated_at", "last_login_at"); err != nil { + return err + } + var f httpx.Fields + p.RequireNonNull(&f, "email", "status") + var newEmail, newStatus string + if p.Has("email") && !p.IsNull("email") { + var e string + if p.Get("email", &e, &f) { + if addr, ok := validEmail(e); ok { + newEmail = addr + } else { + f.Add("email", "invalid_format") + } + } + } + if p.Has("status") && !p.IsNull("status") { + if !admin { + return httpx.E(403, "status_change_forbidden", "Only users.manage can change a User's status.") + } + if p.Get("status", &newStatus, &f) { + switch newStatus { + case "active", "invited", "suspended", "deleted": + default: + f.Enum("status", "active", "invited", "suspended", "deleted") + } + } + } + if err := f.Err(); err != nil { + return err + } + if err := c.destructiveIf(newStatus == "suspended" && u.Status != "suspended"); err != nil { + return err + } + if newEmail != "" && !strings.EqualFold(newEmail, u.Email) { + if _, taken, err := c.emailTaken(newEmail); err != nil { + return err + } else if taken { + return httpx.Conflict("email_taken", "A user with this email already exists.") + } + } + if newStatus != "" && newStatus != u.Status { + if err := c.transitionUser(u, newStatus, newEmail); err != nil { + return err + } + if newEmail != "" { + newEmail = "" // applied inside the restore + } + } + if newEmail != "" && !strings.EqualFold(newEmail, u.Email) { + if admin { + if _, err := c.q.Exec(c.ctx, `update users set email = :e, email_verified = false, pending_email = null + where id = :u`, db.Args{"e": newEmail, "u": u.ID}); err != nil { + if _, ok := db.UniqueViolation(err); ok { + return httpx.Conflict("email_taken", "A user with this email already exists.") + } + return err + } + if err := c.sendVerification(u.ID, newEmail, nil, nil); err != nil { + return err + } + if err := c.audit(Audit{Action: "user.email_changed", TargetType: "user", TargetID: u.ID, TargetUserID: &u.ID, + Before: map[string]any{"email": u.Email}, After: map[string]any{"email": newEmail}}); err != nil { + return err + } + } else { + if _, err := c.q.Exec(c.ctx, `update users set pending_email = :e where id = :u`, + db.Args{"e": newEmail, "u": u.ID}); err != nil { + return err + } + if err := c.sendVerification(u.ID, newEmail, nil, map[string]any{"new_email": newEmail}); err != nil { + return err + } + c.notice(u.Email, "email_change_requested", "Your Substratal email is changing", + "A change of your account's email address to "+newEmail+" was requested. If this wasn't you, contact support.") + } + } + after, err := c.findUser("u.id = :id", db.Args{"id": u.ID}) + if err != nil { + return err + } + c.Versioned(after.Version) + return c.OK(after) + }) +} + +// transitionUser applies a users.manage status change (Users → PATCH +// transition table), with its sessions, events, and audit. +func (c *Call) transitionUser(u userRow, to, restoreEmail string) error { + bad := func() error { + return httpx.Conflict("invalid_status_transition", "The status change from "+u.Status+" to "+to+" isn't allowed.") + } + switch { + case to == "deleted", to == "invited": + return bad() + case u.Status == "active" && to == "suspended": + return c.suspendUser(u, nil) + case u.Status == "suspended" && to == "active": + return c.reactivateUser(u, "user.reactivated", "") + case u.Status == "invited" && to == "active": + if _, err := c.q.Exec(c.ctx, `update users set status = 'active' where id = :u`, db.Args{"u": u.ID}); err != nil { + return err + } + return c.audit(Audit{Action: "user.updated", TargetType: "user", TargetID: u.ID, TargetUserID: &u.ID, + Before: map[string]any{"status": "invited"}, After: map[string]any{"status": "active"}}) + case u.Status == "deleted" && to == "active": + var er struct { + Status string `json:"status"` + } + err := db.One(c.ctx, c.q, &er, `select to_jsonb(e) from erasure_requests e where user_id = :u`, db.Args{"u": u.ID}) + if err == nil && er.Status == "scheduled" { + return httpx.Conflict("erasure_scheduled", "Cancel the scheduled erasure before restoring this User.") + } + if err == nil && er.Status == "completed" { + return httpx.NotFoundResource("user") + } + if err != nil && err != db.ErrNotFound { + return err + } + email := u.Email + if restoreEmail != "" { + email = restoreEmail + } + if _, taken, err := c.emailTaken(email); err != nil { + return err + } else if taken { + return httpx.Conflict("email_taken", "A new account has taken this email; change the email in the same request.") + } + return c.reactivateUser(u, "user.reactivated", restoreEmail) + } + return bad() +} + +// suspendUser: revoke every session and fire access.revoked for every +// Application the User had access to; Entitlements are untouched. +func (c *Call) suspendUser(u userRow, reason *string) error { + pairs, err := c.userAppPairs(u.ID) + if err != nil { + return err + } + watch, err := c.watchAccess(pairs) + if err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update users set status = 'suspended' where id = :u`, db.Args{"u": u.ID}); err != nil { + return err + } + if err := c.revokeSessions(u.ID, "suspended", ""); err != nil { + return err + } + if err := c.flip(watch, "user_reactivated", "user_suspended"); err != nil { + return err + } + after := map[string]any{"status": "suspended"} + if reason != nil { + after["reason"] = *reason + } + if err := c.emit("user.suspended", nil, map[string]any{"user_id": u.ID}); err != nil { + return err + } + return c.audit(Audit{Action: "user.suspended", TargetType: "user", TargetID: u.ID, TargetUserID: &u.ID, + Before: map[string]any{"status": u.Status}, After: after}) +} + +func (c *Call) reactivateUser(u userRow, action, newEmail string) error { + pairs, err := c.userAppPairs(u.ID) + if err != nil { + return err + } + watch, err := c.watchAccess(pairs) + if err != nil { + return err + } + if newEmail != "" { + _, err = c.q.Exec(c.ctx, `update users set status = 'active', deleted_at = null, email = :e, email_verified = false + where id = :u`, db.Args{"u": u.ID, "e": newEmail}) + } else { + _, err = c.q.Exec(c.ctx, `update users set status = 'active', deleted_at = null where id = :u`, db.Args{"u": u.ID}) + } + if err != nil { + if _, ok := db.UniqueViolation(err); ok { + return httpx.Conflict("email_taken", "A new account has taken this email; change the email in the same request.") + } + return err + } + if err := c.flip(watch, "user_reactivated", "user_suspended"); err != nil { + return err + } + if err := c.emit("user.reactivated", nil, map[string]any{"user_id": u.ID}); err != nil { + return err + } + return c.audit(Audit{Action: action, TargetType: "user", TargetID: u.ID, TargetUserID: &u.ID, + Before: map[string]any{"status": u.Status}, After: map[string]any{"status": "active"}}) +} + +// softDeleteUser is DELETE /v1/users/{id}'s effect, shared with erasure. +func (c *Call) softDeleteUser(u userRow) error { + pairs, err := c.userAppPairs(u.ID) + if err != nil { + return err + } + watch, err := c.watchAccess(pairs) + if err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update users set status = 'deleted', deleted_at = now() where id = :u`, db.Args{"u": u.ID}); err != nil { + return err + } + if err := c.revokeSessions(u.ID, "deleted", ""); err != nil { + return err + } + if err := c.flip(watch, "user_reactivated", "user_deleted"); err != nil { + return err + } + if err := c.emit("user.deleted", nil, map[string]any{"user_id": u.ID}); err != nil { + return err + } + return c.audit(Audit{Action: "user.deleted", TargetType: "user", TargetID: u.ID, TargetUserID: &u.ID, + Before: map[string]any{"status": u.Status}, After: map[string]any{"status": "deleted"}}) +} + +func (s *Server) UsersDelete(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe) { + s.serve(w, r, opts{op: "users.delete"}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + if !c.isSelf(uid) && !c.can("users.manage") { + return httpx.Forbidden("users.manage") + } + u, err := c.visibleUser(uid) + if err != nil { + return err + } + if err := httpx.CheckIfMatch(c.r, u.Version); err != nil { + return err + } + if u.Status == "deleted" { + return c.NoContent() + } + if err := c.softDeleteUser(u); err != nil { + return err + } + return c.NoContent() + }) +} + +func (s *Server) UsersSuspend(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe) { + s.serve(w, r, opts{op: "users.suspend"}, func(c *Call) error { + if err := c.require("users.manage"); err != nil { + return err + } + uid, err := c.selfID(id) + if err != nil { + return err + } + var in struct { + Reason *string `json:"reason"` + } + if err := c.Decode(&in); err != nil { + return err + } + if in.Reason != nil && len([]rune(*in.Reason)) > 2000 { + var f httpx.Fields + f.Max("reason", "too_long", 2000) + return f.Err() + } + u, err := c.visibleUser(uid) + if err != nil { + return err + } + switch u.Status { + case "suspended": + case "active": + if err := c.suspendUser(u, in.Reason); err != nil { + return err + } + default: + return httpx.Conflict("invalid_status_transition", "Only an active User can be suspended.") + } + after, err := c.findUser("u.id = :id", db.Args{"id": u.ID}) + if err != nil { + return err + } + c.Versioned(after.Version) + return c.OK(after) + }) +} + +func (s *Server) UsersResendInvitation(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe) { + s.serve(w, r, opts{op: "users.resendInvitation"}, func(c *Call) error { + if err := c.require("users.manage"); err != nil { + return err + } + uid, err := c.selfID(id) + if err != nil { + return err + } + u, err := c.visibleUser(uid) + if err != nil { + return err + } + if u.Status != "invited" { + return httpx.Conflict("invalid_status_transition", "Only an invited User can be re-sent an invitation.") + } + if err := c.sendInvitation(u.ID, u.Email, u.SignupApplicationID); err != nil { + return err + } + return c.Accepted(nil) + }) +} + +// ── Erasure (NFR → Hard-delete cascade) ──────────────────────────────── + +type erasureRow struct { + UserID string `json:"user_id"` + Status string `json:"status"` + RequestedAt time.Time `json:"requested_at"` + ScheduledFor time.Time `json:"scheduled_for"` +} + +func (s *Server) UsersRequestErasure(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe) { + s.serve(w, r, opts{op: "users.requestErasure", idem: idemOptional}, func(c *Call) error { + uid, err := c.selfID(id) + if err != nil { + return err + } + if !c.isSelf(uid) && !c.can("users.manage") { + return httpx.Forbidden("users.manage") + } + var in struct { + Reason *string `json:"reason"` + } + if err := c.Decode(&in); err != nil { + return err + } + u, err := c.visibleUser(uid) + if err != nil { + return err + } + var existing erasureRow + err = db.One(c.ctx, c.q, &existing, `select jsonb_build_object('user_id', user_id, 'status', status, + 'requested_at', requested_at, 'scheduled_for', scheduled_for) from erasure_requests where user_id = :u`, + db.Args{"u": uid}) + if err == nil && existing.Status == "scheduled" { + return c.Accepted(existing) + } + if err == nil && existing.Status == "completed" { + return httpx.NotFoundResource("user") + } + if err != nil && err != db.ErrNotFound { + return err + } + if u.Status != "deleted" { + if err := c.softDeleteUser(u); err != nil { + return err + } + } + typ, actorID := c.actor() + var er erasureRow + if err := db.One(c.ctx, c.q, &er, `insert into erasure_requests (user_id, requested_by_type, requested_by_id, reason, + requested_at, scheduled_for, status) + values (:u, :t, :a, :r, now(), now() + interval '7 days', 'scheduled') + on conflict (user_id) do update set requested_by_type = excluded.requested_by_type, + requested_by_id = excluded.requested_by_id, reason = excluded.reason, + requested_at = now(), scheduled_for = now() + interval '7 days', status = 'scheduled', completed_at = null + returning jsonb_build_object('user_id', user_id, 'status', status, 'requested_at', requested_at, + 'scheduled_for', scheduled_for)`, + db.Args{"u": uid, "t": typ, "a": actorID, "r": in.Reason}); err != nil { + return err + } + if err := c.audit(Audit{Action: "user.erasure_requested", TargetType: "user", TargetID: uid, TargetUserID: &uid, + After: map[string]any{"scheduled_for": er.ScheduledFor}}); err != nil { + return err + } + return c.Accepted(er) + }) +} + +func (s *Server) UsersCancelErasure(w http.ResponseWriter, r *http.Request, id gen.UserIdOrMe) { + s.serve(w, r, opts{op: "users.cancelErasure"}, func(c *Call) error { + if err := c.require("users.manage"); err != nil { + return err + } + uid, err := c.selfID(id) + if err != nil { + return err + } + if _, err := c.visibleUser(uid); err != nil { + return err + } + n, err := c.q.Exec(c.ctx, `update erasure_requests set status = 'cancelled' + where user_id = :u and status = 'scheduled'`, db.Args{"u": uid}) + if err != nil { + return err + } + if n == 0 { + return httpx.E(404, "erasure_not_scheduled", "No erasure is scheduled for this User.") + } + if err := c.audit(Audit{Action: "user.erasure_cancelled", TargetType: "user", TargetID: uid, TargetUserID: &uid}); err != nil { + return err + } + return c.NoContent() + }) +} diff --git a/internal/core/users_create.go b/internal/core/users_create.go new file mode 100644 index 0000000..69da024 --- /dev/null +++ b/internal/core/users_create.go @@ -0,0 +1,99 @@ +package core + +import ( + "net/mail" + "strings" + + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/ids" +) + +// newUser is everything createUser needs. Signup, POST /v1/users, and an +// Organization inviting an unknown email all create Users the same way. +type newUser struct { + Email string + Status string // active | invited + DisplayName string + SignupApp *string + EmailVerify bool // email_verified at creation + PasswordHash string + ActorType string // system for signup + ActorID string +} + +// validEmail checks an address's syntax and normalizes it. +func validEmail(e string) (string, bool) { + e = strings.TrimSpace(e) + a, err := mail.ParseAddress(e) + if err != nil || a.Address != e || len(e) > 255 || !strings.Contains(e[strings.LastIndex(e, "@"):], ".") { + return "", false + } + return e, true +} + +// emailTaken returns the existing live (non-deleted) User with this email +// in the transaction's mode, if any. +func (c *Call) emailTaken(addr string) (userRow, bool, error) { + u, err := c.findUser("u.email = :e and u.deleted_at is null", db.Args{"e": addr}) + if err == db.ErrNotFound { + return u, false, nil + } + return u, err == nil, err +} + +// createUser creates the User, an optional password identity, a default +// Profile and Settings, and the member platform Role, in the current +// transaction (NFR → Transaction boundaries), and writes user.created. +func (c *Call) createUser(n newUser) (userRow, error) { + id := ids.New(ids.User) + display := n.DisplayName + if display == "" { + display = n.Email[:strings.Index(n.Email, "@")] + } + tm := c.testMode() + if _, err := c.q.Exec(c.ctx, `insert into users (id, email, email_verified, status, signup_application_id, test_mode) + values (:id, :e, :v, :st, :app, :tm)`, + db.Args{"id": id, "e": n.Email, "v": n.EmailVerify, "st": n.Status, "app": n.SignupApp, "tm": tm}); err != nil { + if _, ok := db.UniqueViolation(err); ok { + return userRow{}, httpx.Conflict("email_taken", "A user with this email already exists.") + } + return userRow{}, err + } + if n.PasswordHash != "" { + if _, err := c.q.Exec(c.ctx, `insert into user_identities (id, user_id, method, password_hash) + values (:id, :u, 'password', :h)`, db.Args{"id": ids.New(ids.UserIdentity), "u": id, "h": n.PasswordHash}); err != nil { + return userRow{}, err + } + } + if _, err := c.q.Exec(c.ctx, `insert into profiles (user_id, display_name, test_mode) values (:u, :d, :tm)`, + db.Args{"u": id, "d": truncate(display, 100), "tm": tm}); err != nil { + return userRow{}, err + } + if _, err := c.q.Exec(c.ctx, `insert into settings (user_id, test_mode) values (:u, :tm)`, + db.Args{"u": id, "tm": tm}); err != nil { + return userRow{}, err + } + if _, err := c.q.Exec(c.ctx, `insert into user_role_assignments (user_id, role_id, application_id, + assigned_by_type, assigned_by_id, test_mode) + values (:u, 'role_platform_member', null, 'system', 'system', :tm)`, db.Args{"u": id, "tm": tm}); err != nil { + return userRow{}, err + } + u, err := c.findUser("u.id = :id", db.Args{"id": id}) + if err != nil { + return u, err + } + a := Audit{Action: "user.created", TargetType: "user", TargetID: id, TargetUserID: &id, After: u} + if n.ActorType != "" { + a.ActorType, a.ActorID = n.ActorType, n.ActorID + } + return u, c.audit(a) +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) > n { + return string(r[:n]) + } + return s +} diff --git a/internal/core/webhooks.go b/internal/core/webhooks.go new file mode 100644 index 0000000..23825b1 --- /dev/null +++ b/internal/core/webhooks.go @@ -0,0 +1,576 @@ +package core + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/api/gen" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/ids" +) + +// WebhookEventTypes is every event type a subscription can name. +var WebhookEventTypes = []string{ + "access.granted", "access.revoked", + "entitlement.granted", "entitlement.disabled", "entitlement.revoked", "entitlement.expired", + "entitlement.updated", "entitlement.member_scope_changed", "entitlement.deleted", + "role.assigned", "role.removed", "user.suspended", "user.reactivated", "user.deleted", + "organization.member_added", "organization.member_removed", "application.review_status_changed", "webhook.test", +} + +// PublishedWebhookVersions are the payload versions a subscription may pin. +var PublishedWebhookVersions = []string{"2026-10-05"} + +type webhook struct { + ID string `json:"id"` + Scope string `json:"scope"` + URL string `json:"url"` + Events []string `json:"events"` + Description *string `json:"description"` + Status string `json:"status"` + SigningSecret string `json:"signing_secret,omitempty"` + APIVersion string `json:"api_version"` + TestMode bool `json:"test_mode"` + ConsecutiveFailures int `json:"consecutive_failures"` + LastDeliveryAt *time.Time `json:"last_delivery_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + version int + tenantID *string +} + +const webhookSQL = `select jsonb_build_object('id', w.id, 'scope', w.scope, 'url', w.url, 'events', w.events, + 'description', w.description, 'status', w.status, 'api_version', w.api_version, 'test_mode', w.test_mode, + 'consecutive_failures', w.consecutive_failures, 'last_delivery_at', w.last_delivery_at, + 'created_at', w.created_at, 'updated_at', w.updated_at, 'version_', w.version, 'tenant_', w.tenant_id) + from webhook_subscriptions w` + +func (c *Call) queryWebhooks(where string, args db.Args) ([]webhook, error) { + raw, err := c.q.Query(c.ctx, webhookSQL+" where "+where, args) + if err != nil { + return nil, err + } + out := make([]webhook, 0, len(raw)) + for _, x := range raw { + var w struct { + webhook + V int `json:"version_"` + T *string `json:"tenant_"` + } + if err := json.Unmarshal(x, &w); err != nil { + return nil, err + } + w.webhook.version, w.webhook.tenantID = w.V, w.T + out = append(out, w.webhook) + } + return out, nil +} + +// canManageWebhookScope: app scope → its own keys and its owner (no extra +// permission), plus webhooks.manage; platform scope → webhooks.manage. +func (c *Call) canManageWebhookScope(scope string) (bool, error) { + if c.can("webhooks.manage") { + return true, nil + } + if scope == "platform" { + return false, nil + } + if app, ok := c.p.AppKey(); ok { + return app == scope, nil + } + a, err := c.loadApp(scope) + if err != nil { + return false, nil + } + return c.isAppOwner(a) +} + +func (c *Call) loadManagedWebhook(id string) (webhook, error) { + ws, err := c.queryWebhooks("w.id = :id", db.Args{"id": id}) + if err != nil { + return webhook{}, err + } + if len(ws) == 0 { + return webhook{}, httpx.NotFoundResource("webhook") + } + if ok, err := c.canManageWebhookScope(ws[0].Scope); err != nil { + return webhook{}, err + } else if !ok { + return webhook{}, httpx.NotFoundResource("webhook") + } + return ws[0], nil +} + +func invalidWebhookURL(why string) error { + return httpx.E(422, "invalid_webhook_url", "The webhook URL isn't allowed: "+why+".") +} + +// AllowPrivateWebhookTargets disables the public-IP check, for local +// development and tests only (a receiver on localhost). Never in production. +var AllowPrivateWebhookTargets = false + +// validateWebhookURL: https, no userinfo, resolving only to public +// addresses (SSRF guard). The delivery worker re-checks at send time. +func validateWebhookURL(ctx context.Context, raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return invalidWebhookURL("not a URL") + } + if u.User != nil { + return invalidWebhookURL("credentials in the URL") + } + if AllowPrivateWebhookTargets { + if u.Scheme != "https" && u.Scheme != "http" { + return invalidWebhookURL("not http(s)") + } + return nil + } + if u.Scheme != "https" { + return invalidWebhookURL("not https") + } + return CheckPublicHost(ctx, u.Hostname()) +} + +// CheckPublicHost resolves host and rejects private, loopback, link-local, +// and other non-public addresses. +func CheckPublicHost(ctx context.Context, host string) error { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil || len(addrs) == 0 { + return invalidWebhookURL("doesn't resolve") + } + for _, a := range addrs { + ip := a.IP + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsUnspecified() || ip.IsMulticast() || ip.IsInterfaceLocalMulticast() || isCGNAT(ip) { + return invalidWebhookURL("resolves to a non-public address") + } + } + return nil +} + +func isCGNAT(ip net.IP) bool { + _, n, _ := net.ParseCIDR("100.64.0.0/10") + return n.Contains(ip) +} + +func validateEvents(events []string) error { + if len(events) == 0 { + var f httpx.Fields + f.Add("events", "required") + return f.Err() + } + if len(events) == 1 && events[0] == "*" { + return nil + } + seen := map[string]bool{} + for _, e := range events { + if !slices.Contains(WebhookEventTypes, e) { + return httpx.E(422, "unknown_event_type", "Unknown event type: "+e+".").With("event", e) + } + if seen[e] { + var f httpx.Fields + f.Add("events", "not_unique").Message = e + return f.Err() + } + seen[e] = true + } + return nil +} + +func (s *Server) WebhooksList(w http.ResponseWriter, r *http.Request, params gen.WebhooksListParams) { + s.serve(w, r, opts{op: "webhooks.list"}, func(c *Call) error { + where, args := []string{"true"}, db.Args{} + if !c.can("webhooks.manage") { + if app, ok := c.p.AppKey(); ok { + where = append(where, "w.scope = :app") + args["app"] = app + } else if c.p.IsUser() { + where = append(where, `w.scope in (select a.id from applications a where a.owner_user_id = :me + or a.owner_organization_id in (select organization_id from organization_memberships + where user_id = :me and role = 'org_admin' and status = 'active'))`) + args["me"] = c.p.UserID + } else { + return c.OK(httpx.List[webhook]{Data: []webhook{}}) + } + } + if params.ApplicationId != nil { + where = append(where, "w.scope = :scope") + args["scope"] = *params.ApplicationId + } + lim := httpx.Limit(params.Limit) + args["lim"] = int64(lim + 1) + filters := httpx.Filters(c.r) + pos, err := c.s.Cursors.Decode(deref(params.Cursor), filters) + if err != nil { + return err + } + if pos != nil { + where = append(where, "(w.created_at, w.id) < (:ps::timestamptz, :pi)") + args["ps"], args["pi"] = pos.Sort, pos.ID + } + rows, err := c.queryWebhooks(strings.Join(where, " and ")+" order by w.created_at desc, w.id desc limit :lim", args) + if err != nil { + return err + } + return c.OK(httpx.Paginate(c.s.Cursors, rows, lim, filters, func(x webhook) (string, string) { + return httpx.TimeKey(x.CreatedAt), x.ID + })) + }) +} + +func (s *Server) WebhooksCreate(w http.ResponseWriter, r *http.Request) { + s.serve(w, r, opts{op: "webhooks.create", idem: idemOptional}, func(c *Call) error { + var in struct { + Scope *string `json:"scope"` + URL string `json:"url"` + Events []string `json:"events"` + Description *string `json:"description"` + } + if err := c.Decode(&in); err != nil { + return err + } + scope := "" + if in.Scope != nil { + scope = *in.Scope + } + if app, ok := c.p.AppKey(); ok { + if scope == "" { + scope = app + } + if scope != app { + return httpx.Forbidden("") + } + } + if scope == "" { + return httpx.Invalid("scope is required.") + } + if ok, err := c.canManageWebhookScope(scope); err != nil { + return err + } else if !ok { + return httpx.Forbidden("webhooks.manage") + } + if in.Description != nil && len([]rune(*in.Description)) > 2000 { + var f httpx.Fields + f.Max("description", "too_long", 2000) + return f.Err() + } + if err := validateEvents(in.Events); err != nil { + return err + } + if err := validateWebhookURL(c.ctx, in.URL); err != nil { + return err + } + var tenantID *string + if scope != "platform" { + a, err := c.loadApp(scope) + if err != nil { + return httpx.NotFoundResource("application") + } + t, err := c.loadTenant(a.TenantID) + if err != nil { + return err + } + if err := tenantWritable(t); err != nil { + return err + } + if t.Restricted { + return subscriptionRequired(t) + } + if l := plans[t.Plan].Webhooks; l != nil { + var n int + if err := db.One(c.ctx, c.q, &n, `select to_jsonb(count(*)) from webhook_subscriptions where tenant_id = :t`, + db.Args{"t": t.ID}); err != nil { + return err + } + if n >= *l { + return planLimitErr("webhooks", *l, n, t) + } + } + tenantID = &t.ID + } + secret := ids.Secret(ids.WebhookSecret) + enc, err := c.s.Encrypter.Encrypt(c.ctx, secret) + if err != nil { + return err + } + id := ids.New(ids.Webhook) + if _, err := c.q.Exec(c.ctx, `insert into webhook_subscriptions (id, scope, tenant_id, url, events, description, + api_version, secret_ciphertext, test_mode) + values (:id, :s, :t, :u, :e::text[], :d, :v, :sec, :tm)`, + db.Args{"id": id, "s": scope, "t": tenantID, "u": in.URL, "e": db.TextArray(in.Events), "d": in.Description, + "v": webhookAPIVersion, "sec": enc, "tm": c.testMode()}); err != nil { + return err + } + ws, err := c.queryWebhooks("w.id = :id", db.Args{"id": id}) + if err != nil { + return err + } + out := ws[0] + if err := c.audit(Audit{Action: "webhook.created", TargetType: "webhook", TargetID: id, ApplicationID: appIDOf(scope), + TenantID: tenantID, After: out}); err != nil { + return err + } + out.SigningSecret = secret + c.Versioned(out.version) + return c.Created(out) + }) +} + +func (s *Server) WebhooksGet(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "webhooks.get"}, func(c *Call) error { + x, err := c.loadManagedWebhook(id) + if err != nil { + return err + } + c.Versioned(x.version) + return c.OK(x) + }) +} + +func (s *Server) WebhooksUpdate(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "webhooks.update"}, func(c *Call) error { + x, err := c.loadManagedWebhook(id) + if err != nil { + return err + } + if err := httpx.CheckIfMatch(c.r, x.version); err != nil { + return err + } + p, err := c.Patch() + if err != nil { + return err + } + if err := p.RejectReadOnly("id", "scope", "test_mode", "created_at", "updated_at", "consecutive_failures", + "last_delivery_at", "signing_secret"); err != nil { + return err + } + var f httpx.Fields + p.RequireNonNull(&f, "url", "events", "api_version", "status") + next := x + if v, present := p.String("url", &f); present && v != nil { + if err := validateWebhookURL(c.ctx, *v); err != nil { + return err + } + next.URL = *v + } + if p.Has("events") { + var events []string // fresh: next.Events shares x's backing array + if p.Get("events", &events, &f) { + if err := validateEvents(events); err != nil { + return err + } + next.Events = events + } + } + if p.Has("description") { + next.Description, _ = p.String("description", &f) + } + if v, present := p.String("api_version", &f); present && v != nil { + idx := slices.Index(PublishedWebhookVersions, *v) + if idx < 0 || idx < slices.Index(PublishedWebhookVersions, x.APIVersion) { + f.Add("api_version", "unknown_value").Allowed = PublishedWebhookVersions + } + next.APIVersion = *v + } + reenable := false + if v, present := p.String("status", &f); present && v != nil && *v != x.Status { + if !(x.Status == "disabled" && *v == "healthy") { + return httpx.Conflict("invalid_status_transition", "Only disabled → healthy is allowed.") + } + next.Status, reenable = *v, true + } + if err := f.Err(); err != nil { + return err + } + resetSQL := "" + if reenable { + resetSQL = ", consecutive_failures = 0, unhealthy_since = null" + } + if _, err := c.q.Exec(c.ctx, `update webhook_subscriptions set url = :u, events = :e::text[], description = :d, + api_version = :v, status = :st`+resetSQL+` where id = :id`, + db.Args{"u": next.URL, "e": db.TextArray(next.Events), "d": next.Description, "v": next.APIVersion, + "st": next.Status, "id": x.ID}); err != nil { + return err + } + after, err := c.loadManagedWebhook(x.ID) + if err != nil { + return err + } + if err := c.audit(Audit{Action: "webhook.updated", TargetType: "webhook", TargetID: x.ID, ApplicationID: appIDOf(x.Scope), + TenantID: x.tenantID, Before: x, After: after}); err != nil { + return err + } + c.Versioned(after.version) + return c.OK(after) + }) +} + +func (s *Server) WebhooksDelete(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "webhooks.delete"}, func(c *Call) error { + x, err := c.loadManagedWebhook(id) + if err != nil { + return err + } + if err := httpx.CheckIfMatch(c.r, x.version); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `delete from webhook_subscriptions where id = :id`, db.Args{"id": x.ID}); err != nil { + return err + } + if err := c.audit(Audit{Action: "webhook.deleted", TargetType: "webhook", TargetID: x.ID, ApplicationID: appIDOf(x.Scope), + TenantID: x.tenantID, Before: x}); err != nil { + return err + } + return c.NoContent() + }) +} + +func (s *Server) WebhooksRotateSecret(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "webhooks.rotateSecret"}, func(c *Call) error { + x, err := c.loadManagedWebhook(id) + if err != nil { + return err + } + secret := ids.Secret(ids.WebhookSecret) + enc, err := c.s.Encrypter.Encrypt(c.ctx, secret) + if err != nil { + return err + } + expires := c.now.Add(24 * time.Hour) + if _, err := c.q.Exec(c.ctx, `update webhook_subscriptions set previous_secret_ciphertext = secret_ciphertext, + previous_secret_expires_at = :exp::timestamptz, secret_ciphertext = :s where id = :id`, + db.Args{"exp": db.Time(expires), "s": enc, "id": x.ID}); err != nil { + return err + } + if err := c.audit(Audit{Action: "webhook.secret_rotated", TargetType: "webhook", TargetID: x.ID, + ApplicationID: appIDOf(x.Scope), TenantID: x.tenantID, + After: map[string]any{"previous_secret_expires_at": expires}}); err != nil { + return err + } + return c.OK(map[string]any{"id": x.ID, "signing_secret": secret, "previous_secret_expires_at": expires.Format(time.RFC3339)}) + }) +} + +// deliverNow creates a pending delivery of an existing event to one +// subscription, outside the normal fan-out (test, redeliver). +func (c *Call) deliverNow(subID, eventID string) (string, error) { + did := ids.New(ids.Delivery) + _, err := c.q.Exec(c.ctx, `insert into webhook_deliveries (id, subscription_id, event_id, attempt, status, next_retry_at, test_mode) + values (:id, :s, :e, 1, 'pending', now(), :tm)`, db.Args{"id": did, "s": subID, "e": eventID, "tm": c.testMode()}) + if err != nil { + return "", err + } + c.After(func(ctx contextT) { c.s.Notifier.EventCommitted(ctx, "delivery:"+did) }) + return did, nil +} + +func (s *Server) WebhooksSendTest(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "webhooks.sendTest"}, func(c *Call) error { + x, err := c.loadManagedWebhook(id) + if err != nil { + return err + } + eid := ids.New(ids.WebhookEvent) + // Delivered whatever the events filter says, even when disabled, and + // never fanned out to anyone else: dispatched_at is set up front. + if _, err := c.q.Exec(c.ctx, `insert into webhook_events (id, type, application_id, payload, test_mode, dispatched_at) + values (:id, 'webhook.test', :app, :p::jsonb, :tm, now())`, + db.Args{"id": eid, "app": appIDOf(x.Scope), "p": db.JSON(map[string]any{"message": "Test event from Substratal"}), + "tm": x.TestMode}); err != nil { + return err + } + did, err := c.deliverNow(x.ID, eid) + if err != nil { + return err + } + return c.Accepted(map[string]any{"delivery_id": did, "event_id": eid}) + }) +} + +type delivery struct { + ID string `json:"id"` + EventID string `json:"event_id"` + EventType string `json:"event_type"` + Attempt int `json:"attempt"` + Status string `json:"status"` + ResponseStatus *int `json:"response_status"` + DurationMS *int `json:"duration_ms"` + Error *string `json:"error"` + NextRetryAt *time.Time `json:"next_retry_at"` + CreatedAt time.Time `json:"created_at"` +} + +func (s *Server) WebhooksListDeliveries(w http.ResponseWriter, r *http.Request, id string, params gen.WebhooksListDeliveriesParams) { + s.serve(w, r, opts{op: "webhooks.listDeliveries"}, func(c *Call) error { + if _, err := c.loadManagedWebhook(id); err != nil { + return err + } + where := []string{"d.subscription_id = :s", "d.created_at > now() - interval '30 days'"} + args := db.Args{"s": id} + if params.EventId != nil { + where = append(where, "d.event_id = :e") + args["e"] = *params.EventId + } + if params.Status != nil { + where = append(where, "d.status = :st") + args["st"] = string(*params.Status) + } + if params.Since != nil { + where = append(where, "d.created_at >= :since::timestamptz") + args["since"] = db.Time(*params.Since) + } + lim := httpx.Limit(params.Limit) + args["lim"] = int64(lim + 1) + filters := httpx.Filters(c.r) + pos, err := c.s.Cursors.Decode(deref(params.Cursor), filters) + if err != nil { + return err + } + if pos != nil { + where = append(where, "(d.created_at, d.id) < (:ps::timestamptz, :pi)") + args["ps"], args["pi"] = pos.Sort, pos.ID + } + rows, err := db.All[delivery](c.ctx, c.q, `select jsonb_build_object('id', d.id, 'event_id', d.event_id, + 'event_type', e.type, 'attempt', d.attempt, 'status', d.status, 'response_status', d.response_status, + 'duration_ms', d.duration_ms, 'error', d.error, 'next_retry_at', d.next_retry_at, 'created_at', d.created_at) + from webhook_deliveries d join webhook_events e on e.id = d.event_id + where `+strings.Join(where, " and ")+` order by d.created_at desc, d.id desc limit :lim`, args) + if err != nil { + return err + } + return c.OK(httpx.Paginate(c.s.Cursors, rows, lim, filters, func(x delivery) (string, string) { + return httpx.TimeKey(x.CreatedAt), x.ID + })) + }) +} + +func (s *Server) WebhooksRedeliver(w http.ResponseWriter, r *http.Request, id string, deliveryID string) { + s.serve(w, r, opts{op: "webhooks.redeliver"}, func(c *Call) error { + if _, err := c.loadManagedWebhook(id); err != nil { + return err + } + var eventID string + err := db.One(c.ctx, c.q, &eventID, `select to_jsonb(event_id) from webhook_deliveries + where id = :d and subscription_id = :s and created_at > now() - interval '30 days'`, db.Args{"d": deliveryID, "s": id}) + if err == db.ErrNotFound { + return httpx.NotFoundResource("delivery") + } + if err != nil { + return err + } + did, err := c.deliverNow(id, eventID) + if err != nil { + return err + } + return c.Accepted(map[string]any{"delivery_id": did, "event_id": eventID}) + }) +} diff --git a/internal/core/webhooks_test.go b/internal/core/webhooks_test.go new file mode 100644 index 0000000..09a17cd --- /dev/null +++ b/internal/core/webhooks_test.go @@ -0,0 +1,400 @@ +package core_test + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/testenv" +) + +type receiver struct { + mu sync.Mutex + got []map[string]any + sigs []string + status int + srv *httptest.Server + raw [][]byte +} + +func newReceiver(t *testing.T) *receiver { + r := &receiver{status: 200} + r.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + b, _ := io.ReadAll(req.Body) + var m map[string]any + _ = json.Unmarshal(b, &m) + r.mu.Lock() + r.got = append(r.got, m) + r.raw = append(r.raw, b) + r.sigs = append(r.sigs, req.Header.Get("Substratal-Signature")) + st := r.status + r.mu.Unlock() + w.WriteHeader(st) + })) + t.Cleanup(r.srv.Close) + return r +} + +func (r *receiver) types() []string { + r.mu.Lock() + defer r.mu.Unlock() + var out []string + for _, m := range r.got { + out = append(out, m["type"].(string)) + } + return out +} + +// verify checks the newest delivery's signature as the docs tell receivers to. +func verify(t *testing.T, raw []byte, header string, secrets ...string) bool { + var ts string + var sigs []string + for _, p := range strings.Split(header, ",") { + k, v, _ := strings.Cut(p, "=") + if k == "t" { + ts = v + } else if k == "v1" { + sigs = append(sigs, v) + } + } + for _, s := range secrets { + mac := hmac.New(sha256.New, []byte(s)) + mac.Write([]byte(ts + ".")) + mac.Write(raw) + want := hex.EncodeToString(mac.Sum(nil)) + for _, got := range sigs { + if hmac.Equal([]byte(got), []byte(want)) { + return true + } + } + } + return false +} + +func TestWebhookDelivery(t *testing.T) { + e := testenv.New(t) + rcv := newReceiver(t) + app := e.App(system, nil) + sub := e.Post("/v1/webhooks", e.Admin, map[string]any{"scope": app, "url": rcv.srv.URL, + "events": []string{"access.granted", "access.revoked", "role.removed"}}).Expect(201) + secret := sub.Str("signing_secret") + if !strings.HasPrefix(secret, "whsec_") { + t.Fatalf("secret = %s", secret) + } + if e.Get("/v1/webhooks/"+sub.Str("id"), e.Admin).Str("signing_secret") != "" { + t.Fatal("a GET must never return the signing secret") + } + e.Post("/v1/webhooks", e.Admin, map[string]any{"scope": app, "url": rcv.srv.URL, "events": []string{"nope"}}). + ExpectErr(422, "unknown_event_type") + + // A grant fires access.granted to the app's subscription, signed. + u := e.Signup() + ent := e.Grant(e.Admin, u.ID, app) + e.Deliver() + if got := rcv.types(); len(got) != 1 || got[0] != "access.granted" { + t.Fatalf("delivered = %v", got) + } + if !verify(t, rcv.raw[0], rcv.sigs[0], secret) { + t.Fatal("signature doesn't verify") + } + env := rcv.got[0] + if env["api_version"] != "2026-10-05" || env["data"].(map[string]any)["reason"] != "entitlement_granted" { + t.Fatalf("envelope = %v", env) + } + + // Rotation: both secrets sign for 24h. + rot := e.Post("/v1/webhooks/"+sub.Str("id")+"/rotate-secret", e.Admin, map[string]any{}).Expect(200) + e.Patch("/v1/entitlements/"+ent, e.Admin, map[string]any{"status": "disabled", "disabled_reason": "test"}).Expect(200) + e.Deliver() + last := len(rcv.raw) - 1 + if rcv.types()[last] != "access.revoked" || !verify(t, rcv.raw[last], rcv.sigs[last], secret) || + !verify(t, rcv.raw[last], rcv.sigs[last], rot.Str("signing_secret")) { + t.Fatalf("rotation signing: %s", rcv.sigs[last]) + } + + // Another app's events never arrive. + other := e.App(system, nil) + e.Grant(e.Admin, u.ID, other) + e.Deliver() + for _, m := range rcv.got { + if m["application_id"] == other { + t.Fatal("received another Application's event") + } + } + + // Failure schedules a retry; 410 disables the subscription. + rcv.mu.Lock() + rcv.status = 500 + rcv.mu.Unlock() + e.Patch("/v1/entitlements/"+ent, e.Admin, map[string]any{"status": "active"}).Expect(200) + e.Deliver() + dl := e.Get("/v1/webhooks/"+sub.Str("id")+"/deliveries?status=failed", e.Admin).Expect(200).JSON()["data"].([]any) + if len(dl) == 0 || dl[0].(map[string]any)["next_retry_at"] == nil { + t.Fatalf("failed delivery should carry next_retry_at: %v", dl) + } + // Force the retry due, then answer 410. + _ = e.DB.Tx(context.Background(), db.Settings{AllModes: true}, func(q db.Querier) error { + _, err := q.Exec(context.Background(), `update webhook_deliveries set next_retry_at = now() where status = 'pending'`, nil) + return err + }) + rcv.mu.Lock() + rcv.status = 410 + rcv.mu.Unlock() + e.Deliver() + if e.Get("/v1/webhooks/"+sub.Str("id"), e.Admin).Str("status") != "disabled" { + t.Fatal("410 should disable the subscription") + } + // A test event still goes to a disabled subscription. + rcv.mu.Lock() + rcv.status = 200 + rcv.mu.Unlock() + n := len(rcv.got) + e.Post("/v1/webhooks/"+sub.Str("id")+"/test", e.Admin, map[string]any{}).Expect(202) + e.Deliver() + if len(rcv.got) != n+1 || rcv.types()[n] != "webhook.test" { + t.Fatalf("test event = %v", rcv.types()) + } + e.Patch("/v1/webhooks/"+sub.Str("id"), e.Admin, map[string]any{"status": "unhealthy"}).ExpectErr(409, "invalid_status_transition") + e.Patch("/v1/webhooks/"+sub.Str("id"), e.Admin, map[string]any{"status": "healthy"}).Expect(200) + // Starter plan limit: Substratal's Tenant is Enterprise, so use a Starter owner. + owner := e.Signup() + ownApp := e.App(owner.ID, nil) + e.Post("/v1/webhooks", owner.Token, map[string]any{"scope": ownApp, "url": rcv.srv.URL, "events": []string{"*"}}).Expect(201) + e.Post("/v1/webhooks", owner.Token, map[string]any{"scope": ownApp, "url": rcv.srv.URL, "events": []string{"*"}}). + ExpectErr(409, "plan_limit_reached") + e.Post("/v1/webhooks", owner.Token, map[string]any{"scope": "platform", "url": rcv.srv.URL, "events": []string{"*"}}). + ExpectErr(403, "forbidden") +} + +func TestUserLifecycle(t *testing.T) { + e := testenv.New(t) + u := e.Signup() + app := e.App(system, nil) + e.Grant(e.Admin, u.ID, app) + e.Post("/v1/users/"+u.ID+"/suspend", e.Admin, map[string]any{"reason": "SUP-1"}).Expect(200) + e.Get("/v1/users/me", u.Token).ExpectErr(401, "session_revoked") + if accessEventsFor(e, "access.revoked", u.ID, app) != 1 { + t.Fatal("suspension should fire access.revoked") + } + e.Post("/v1/auth/login", "", map[string]any{"email": u.Email, "password": u.Password}).ExpectErr(403, "account_suspended") + e.Patch("/v1/users/"+u.ID, e.Admin, map[string]any{"status": "active"}).Expect(200) + if accessEventsFor(e, "access.granted", u.ID, app) != 2 { + t.Fatal("reactivation should fire access.granted") + } + s := e.Login(u.Email, u.Password) + e.Patch("/v1/users/me", s.Token(), map[string]any{"status": "suspended"}).ExpectErr(403, "status_change_forbidden") + // Self-service email change is pending until verified. + newAddr := "changed+" + testenv.Unique() + "@example.com" + pend := e.Patch("/v1/users/me", s.Token(), map[string]any{"email": newAddr}).Expect(200) + if pend.Str("email") != u.Email || pend.Str("pending_email") != newAddr { + t.Fatalf("pending = %s", pend.Body) + } + e.Post("/v1/auth/email/verify", "", map[string]any{"token": e.Token(newAddr, "email_verify")}).Expect(204) + if e.Get("/v1/users/me", s.Token()).Str("email") != newAddr { + t.Fatal("email change should complete on verify") + } + // Export, then soft-delete, then erasure. + ex := e.Get("/v1/users/me/export", s.Token()).Expect(200).JSON() + for _, k := range []string{"user", "profile", "settings", "identities", "sessions", "roles", "applications", "audit_events"} { + if _, ok := ex[k]; !ok { + t.Fatalf("export missing %s", k) + } + } + e.Post("/v1/users/me/erasure-requests", s.Token(), map[string]any{"reason": "please"}).Expect(202) + e.Get("/v1/users/"+u.ID, e.Admin).Expect(200) // users.manage still sees it + other := e.Signup() + e.Get("/v1/users/"+u.ID, other.Token).ExpectErr(404, "user_not_found") + e.Patch("/v1/users/"+u.ID, e.Admin, map[string]any{"status": "active"}).ExpectErr(409, "erasure_scheduled") + // Run the cascade now. + _ = e.DB.Tx(context.Background(), db.Settings{AllModes: true}, func(q db.Querier) error { + _, err := q.Exec(context.Background(), `update erasure_requests set scheduled_for = now() where user_id = :u`, db.Args{"u": u.ID}) + return err + }) + if n, err := e.Core.RunErasures(context.Background()); err != nil || n != 1 { + t.Fatalf("erasures = %d, %v", n, err) + } + gone := e.Get("/v1/users/"+u.ID, e.Admin).Expect(200) + if !strings.HasPrefix(gone.Str("email"), "erased+") { + t.Fatalf("erased user = %s", gone.Body) + } + ev := e.Get("/v1/audit-events?target_user_id="+u.ID+"&action=entitlement.granted", e.Admin).Expect(200).JSON()["data"].([]any) + if len(ev) != 1 || ev[0].(map[string]any)["after"] != nil { + t.Fatalf("snapshots should be redacted, shape kept: %v", ev) + } + e.Patch("/v1/users/"+u.ID, e.Admin, map[string]any{"status": "active"}).ExpectErr(404, "user_not_found") +} + +func TestAuditVisibility(t *testing.T) { + e := testenv.New(t) + owner := e.Signup() + app := e.App(owner.ID, nil) + u := e.Signup() + e.Grant(e.Admin, u.ID, app) + e.Get("/v1/audit-events", u.Token).ExpectErr(403, "forbidden") + mine := e.Get("/v1/audit-events?target_user_id=me", u.Token).Expect(200).JSON()["data"].([]any) + if len(mine) == 0 { + t.Fatal("a user can read their own trail") + } + tenant := e.Get("/v1/applications/"+app, e.Admin).Str("tenant_id") + own := e.Get("/v1/audit-events?tenant_id="+tenant, owner.Token).Expect(200) + if len(own.JSON()["data"].([]any)) == 0 || own.Header.Get("Audit-Hot-Window-Start") == "" { + t.Fatalf("tenant owner view = %s", own.Body) + } + e.Get("/v1/audit-events?tenant_id="+tenant, u.Token).ExpectErr(403, "forbidden") + id := mine[0].(map[string]any)["id"].(string) + e.Get("/v1/audit-events/"+id, u.Token).Expect(200) + e.Get("/v1/audit-events/"+id, owner.Token).Expect(200) // the Tenant owner's view of their own app + stranger := e.Signup() + e.Get("/v1/audit-events/"+id, stranger.Token).ExpectErr(404, "audit_event_not_found") +} + +func TestTenancyAndUsage(t *testing.T) { + e := testenv.New(t) + owner := e.Signup() + app := e.App(owner.ID, nil) + ts := e.Get("/v1/tenants", owner.Token).Expect(200).JSON()["data"].([]any) + if len(ts) != 1 { + t.Fatalf("owner tenants = %v", ts) + } + tid := ts[0].(map[string]any)["id"].(string) + if ts[0].(map[string]any)["plan"] != "starter" || ts[0].(map[string]any)["application_count"].(float64) != 1 { + t.Fatalf("tenant = %v", ts[0]) + } + u := e.Signup() + e.Grant(owner.Token, u.ID, app) // the owner grants with their own User token + usage := e.Get("/v1/tenants/"+tid+"/usage", owner.Token).Expect(200).JSON() + limits := usage["limits"].(map[string]any) + if limits["seats"].(map[string]any)["current"].(float64) != 1 || limits["applications"].(map[string]any)["limit"].(float64) != 1 { + t.Fatalf("usage = %v", usage) + } + sub := e.Get("/v1/tenants/"+tid+"/subscription", owner.Token).Expect(200).JSON() + if sub["subscription_status"] != "none" || sub["currency"] != "usd" { + t.Fatalf("subscription = %v", sub) + } + e.Get("/v1/tenants/"+tid, u.Token).ExpectErr(404, "tenant_not_found") + // Checkout with no Stripe configured is billing_unavailable; Enterprise needs sales. + e.PostIdem("/v1/tenants/"+tid+"/billing/checkout-sessions", owner.Token, testenv.Unique(), + map[string]any{"plan": "enterprise", "success_url": "https://x.example.com", "cancel_url": "https://x.example.com"}). + ExpectErr(422, "plan_requires_sales") + e.PostIdem("/v1/tenants/"+tid+"/billing/checkout-sessions", owner.Token, testenv.Unique(), + map[string]any{"plan": "team", "success_url": "https://x.example.com", "cancel_url": "https://x.example.com"}). + ExpectErr(503, "billing_unavailable") + e.Post("/v1/tenants/"+tid+"/billing/portal-sessions", owner.Token, map[string]any{"return_url": "https://x.example.com"}). + ExpectErr(409, "no_billing_account") + // Tier changes: tenants.manage only, Enterprise only, up the ladder only. + e.Post("/v1/tenants/"+tid+"/tier-change-requests", owner.Token, map[string]any{"requested_tier": "isolated", "reason": "x"}). + ExpectErr(403, "forbidden") + e.Post("/v1/tenants/"+tid+"/tier-change-requests", e.Admin, map[string]any{"requested_tier": "isolated", "reason": "x"}). + ExpectErr(409, "plan_does_not_allow_tier") + sys := "tnt_01JAG1SYSTEM00000000000000" + e.Post("/v1/tenants/"+sys+"/tier-change-requests", e.Admin, map[string]any{"requested_tier": "dedicated_region", + "requested_region": "mars-1", "reason": "x"}).ExpectErr(422, "unsupported_region") + req := e.Post("/v1/tenants/"+sys+"/tier-change-requests", e.Admin, map[string]any{"requested_tier": "isolated", "reason": "contract"}).Expect(201) + rp := "/v1/tenants/" + sys + "/tier-change-requests/" + req.Str("id") + e.Post("/v1/tenants/"+sys+"/tier-change-requests", e.Admin, map[string]any{"requested_tier": "isolated", "reason": "again"}). + ExpectErr(409, "tier_change_already_pending") + e.Patch(rp, e.Admin, map[string]any{"status": "completed"}).ExpectErr(409, "invalid_request_transition") + e.Patch(rp, e.Admin, map[string]any{"status": "in_progress"}).Expect(200) + if e.Get("/v1/tenants/"+sys, e.Admin).Str("status") != "migrating" { + t.Fatal("in_progress should mark the Tenant migrating") + } + // Writes to a migrating Tenant's data are paused. + e.Post("/v1/applications", e.Admin, map[string]any{"slug": "mig-" + testenv.Unique(), "name": "x", + "launch_url": "https://x.example.com", "owner_user_id": system}).ExpectErr(503, "service_unavailable") + e.Patch(rp, e.Admin, map[string]any{"status": "cancelled"}).ExpectErr(422, "validation_failed") + e.Patch(rp, e.Admin, map[string]any{"status": "cancelled", "notes": "rollback"}).Expect(200) + if e.Get("/v1/tenants/"+sys, e.Admin).Str("status") != "active" { + t.Fatal("cancel should restore the Tenant") + } +} + +func TestPagination(t *testing.T) { + e := testenv.New(t) + for i := 0; i < 5; i++ { + e.Signup() + } + seen := map[string]bool{} + cursor := "" + pages := 0 + for { + path := "/v1/users?limit=2" + if cursor != "" { + path += "&cursor=" + cursor + } + page := e.Get(path, e.Admin).Expect(200).JSON() + for _, u := range page["data"].([]any) { + id := u.(map[string]any)["id"].(string) + if seen[id] { + t.Fatalf("duplicate %s across pages", id) + } + seen[id] = true + } + pages++ + p := page["page"].(map[string]any) + if p["has_more"] != true { + if p["next_cursor"] != nil { + t.Fatal("next_cursor must be null when has_more is false") + } + break + } + cursor = p["next_cursor"].(string) + } + if len(seen) < 6 || pages < 3 { + t.Fatalf("seen %d users across %d pages", len(seen), pages) + } + e.Get("/v1/users?limit=2&cursor=garbage", e.Admin).ExpectErr(400, "invalid_cursor") + first := e.Get("/v1/users?limit=1", e.Admin).JSON()["page"].(map[string]any)["next_cursor"].(string) + e.Get("/v1/users?limit=1&status=active&cursor="+first, e.Admin).ExpectErr(400, "invalid_cursor") +} + +func TestRateLimits(t *testing.T) { + e := testenv.NewWith(t, testenv.Options{RateLimits: true}) + var last *testenv.Resp + for i := 0; i < 11; i++ { + last = e.Post("/v1/auth/login", "", map[string]any{"email": "rl+" + testenv.Unique() + "@example.com", "password": "whatever passphrase"}) + } + last.ExpectErr(429, "rate_limited") + if last.Header.Get("Retry-After") == "" || last.Header.Get("RateLimit-Limit") != "10" { + t.Fatalf("headers = %v", last.Header) + } + ok := e.Get("/v1/users/me", e.Admin).Expect(200) + if ok.Header.Get("RateLimit-Remaining") == "" { + t.Fatal("every response carries RateLimit headers") + } +} + +func TestSweepExpiresAndStarts(t *testing.T) { + e := testenv.New(t) + u := e.Signup() + app := e.App(system, nil) + ends := time.Now().Add(time.Second).UTC().Format(time.RFC3339Nano) + id := e.PostIdem("/v1/users/"+u.ID+"/entitlements", e.Admin, testenv.Unique(), + map[string]any{"application_id": app, "source": "trial", "ends_at": ends}).Expect(201).Str("id") + time.Sleep(1200 * time.Millisecond) + if n, _, err := e.Core.SweepEntitlements(context.Background()); err != nil || n != 1 { + t.Fatalf("expired = %d, %v", n, err) + } + if e.Get("/v1/entitlements/"+id, e.Admin).Str("status") != "expired" { + t.Fatal("sweep should expire the trial") + } + if accessEventsFor(e, "access.revoked", u.ID, app) != 1 || len(e.Events("entitlement.expired")) != 1 { + t.Fatal("expiry should fire entitlement.expired and access.revoked") + } + // Renewal needs a future ends_at. + e.Patch("/v1/entitlements/"+id, e.Admin, map[string]any{"status": "active"}).ExpectErr(409, "invalid_status_transition") + e.Patch("/v1/entitlements/"+id, e.Admin, map[string]any{"status": "active", + "ends_at": time.Now().Add(240 * time.Hour).UTC().Format(time.RFC3339)}).Expect(200) + if err := e.Core.Cleanup(context.Background()); err != nil { + t.Fatalf("cleanup: %v", err) + } +} diff --git a/internal/db/dataapi/dataapi.go b/internal/db/dataapi/dataapi.go new file mode 100644 index 0000000..ddf0919 --- /dev/null +++ b/internal/db/dataapi/dataapi.go @@ -0,0 +1,214 @@ +// Package dataapi implements db.DB over the RDS Data API: IAM-signed HTTPS +// to Aurora Serverless v2, with no VPC attachment (see DEPLOYMENT.md → Why +// no VPC). It's what every Lambda runs in AWS. +package dataapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/rdsdata" + "github.com/aws/aws-sdk-go-v2/service/rdsdata/types" + + "github.com/Adron/substratalapps.com/internal/db" +) + +// DB is a Data API client bound to one cluster, secret, and database. +type DB struct { + client *rdsdata.Client + resourceARN string + secretARN string + database string +} + +// New returns a Data API–backed db.DB. +func New(client *rdsdata.Client, resourceARN, secretARN, database string) *DB { + return &DB{client: client, resourceARN: resourceARN, secretARN: secretARN, database: database} +} + +// Close is a no-op; the Data API is connectionless. +func (d *DB) Close() {} + +// Tx implements db.DB with BeginTransaction/CommitTransaction. +func (d *DB) Tx(ctx context.Context, s db.Settings, fn func(db.Querier) error) error { + begin, err := d.client.BeginTransaction(ctx, &rdsdata.BeginTransactionInput{ + ResourceArn: &d.resourceARN, SecretArn: &d.secretARN, Database: &d.database, + }) + if err != nil { + return err + } + q := querier{d: d, txID: begin.TransactionId} + rollback := func() { + // Best effort: a failed rollback leaves the transaction to time out + // server-side, which is the Data API's own documented behavior. + _, _ = d.client.RollbackTransaction(context.WithoutCancel(ctx), &rdsdata.RollbackTransactionInput{ + ResourceArn: &d.resourceARN, SecretArn: &d.secretARN, TransactionId: begin.TransactionId, + }) + } + if _, err := q.Exec(ctx, db.SetupSQL, s.SetupArgs()); err != nil { + rollback() + return err + } + if err := fn(q); err != nil { + rollback() + return err + } + _, err = d.client.CommitTransaction(ctx, &rdsdata.CommitTransactionInput{ + ResourceArn: &d.resourceARN, SecretArn: &d.secretARN, TransactionId: begin.TransactionId, + }) + return err +} + +// ExecScript implements db.DB: every statement in one Data API transaction. +func (d *DB) ExecScript(ctx context.Context, statements []string) error { + begin, err := d.client.BeginTransaction(ctx, &rdsdata.BeginTransactionInput{ + ResourceArn: &d.resourceARN, SecretArn: &d.secretARN, Database: &d.database, + }) + if err != nil { + return err + } + for _, st := range statements { + st := st + if _, err := d.client.ExecuteStatement(ctx, &rdsdata.ExecuteStatementInput{ + ResourceArn: &d.resourceARN, SecretArn: &d.secretARN, Database: &d.database, + TransactionId: begin.TransactionId, Sql: &st, + }); err != nil { + _, _ = d.client.RollbackTransaction(context.WithoutCancel(ctx), &rdsdata.RollbackTransactionInput{ + ResourceArn: &d.resourceARN, SecretArn: &d.secretARN, TransactionId: begin.TransactionId, + }) + return fmt.Errorf("%w\n--- statement ---\n%s", translate(err), st) + } + } + _, err = d.client.CommitTransaction(ctx, &rdsdata.CommitTransactionInput{ + ResourceArn: &d.resourceARN, SecretArn: &d.secretARN, TransactionId: begin.TransactionId, + }) + return err +} + +// Autocommit implements db.DB: statements run without a transaction id. +func (d *DB) Autocommit() db.Querier { return querier{d: d} } + +type querier struct { + d *DB + txID *string +} + +func (q querier) run(ctx context.Context, sql string, args db.Args) (*rdsdata.ExecuteStatementOutput, error) { + params, err := parameters(sql, args) + if err != nil { + return nil, err + } + out, err := q.d.client.ExecuteStatement(ctx, &rdsdata.ExecuteStatementInput{ + ResourceArn: &q.d.resourceARN, + SecretArn: &q.d.secretARN, + Database: &q.d.database, + TransactionId: q.txID, + Sql: &sql, + Parameters: params, + }) + return out, translate(err) +} + +func (q querier) Query(ctx context.Context, sql string, args db.Args) ([]json.RawMessage, error) { + out, err := q.run(ctx, sql, args) + if err != nil { + return nil, err + } + rows := make([]json.RawMessage, 0, len(out.Records)) + for _, rec := range out.Records { + if len(rec) != 1 { + return nil, fmt.Errorf("dataapi: query must select exactly one json column, got %d", len(rec)) + } + switch f := rec[0].(type) { + case *types.FieldMemberStringValue: + rows = append(rows, json.RawMessage(f.Value)) + case *types.FieldMemberIsNull: + rows = append(rows, json.RawMessage("null")) + default: + return nil, fmt.Errorf("dataapi: expected a json column, got %T", f) + } + } + return rows, nil +} + +func (q querier) Exec(ctx context.Context, sql string, args db.Args) (int64, error) { + out, err := q.run(ctx, sql, args) + if err != nil { + return 0, err + } + return out.NumberOfRecordsUpdated, nil +} + +var paramName = regexp.MustCompile(`(::?)([a-zA-Z_][a-zA-Z0-9_]*)`) + +// parameters builds Data API SqlParameters for every :name the statement +// uses. The Data API binds :name natively, so the SQL text is unchanged. +func parameters(sql string, args db.Args) ([]types.SqlParameter, error) { + seen := map[string]bool{} + var params []types.SqlParameter + for _, m := range paramName.FindAllStringSubmatch(sql, -1) { + if m[1] == "::" || seen[m[2]] { + continue + } + name := m[2] + seen[name] = true + v, ok := args[name] + if !ok { + return nil, fmt.Errorf("dataapi: missing parameter %q", name) + } + field, err := toField(db.Normalize(v)) + if err != nil { + return nil, fmt.Errorf("dataapi: parameter %q: %w", name, err) + } + params = append(params, types.SqlParameter{Name: aws.String(name), Value: field}) + } + return params, nil +} + +func toField(v any) (types.Field, error) { + switch t := v.(type) { + case nil: + return &types.FieldMemberIsNull{Value: true}, nil + case string: + return &types.FieldMemberStringValue{Value: t}, nil + case bool: + return &types.FieldMemberBooleanValue{Value: t}, nil + case int64: + return &types.FieldMemberLongValue{Value: t}, nil + case float64: + return &types.FieldMemberDoubleValue{Value: t}, nil + default: + return nil, fmt.Errorf("unsupported type %T (use db.Time/JSON/TextArray)", v) + } +} + +var sqlState = regexp.MustCompile(`SQLState: (\d{5})`) +var constraintName = regexp.MustCompile(`constraint "([^"]+)"`) + +// translate maps a Data API database error to db.ConstraintError. The +// Data API reports Postgres errors as BadRequestException text, so the +// SQLSTATE and constraint name are parsed from the message. +func translate(err error) error { + if err == nil { + return nil + } + var bre *types.BadRequestException + if !errors.As(err, &bre) || bre.Message == nil { + return err + } + msg := *bre.Message + m := sqlState.FindStringSubmatch(msg) + if m == nil || !strings.HasPrefix(m[1], "23") { + return err + } + ce := &db.ConstraintError{Code: m[1], Message: msg} + if c := constraintName.FindStringSubmatch(msg); c != nil { + ce.Constraint = c[1] + } + return ce +} diff --git a/internal/db/dataapi/dataapi_test.go b/internal/db/dataapi/dataapi_test.go new file mode 100644 index 0000000..d967231 --- /dev/null +++ b/internal/db/dataapi/dataapi_test.go @@ -0,0 +1,55 @@ +package dataapi + +import ( + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/rdsdata/types" + + "github.com/Adron/substratalapps.com/internal/db" +) + +func TestParameters(t *testing.T) { + ps, err := parameters(`select :s, :n::int, :b, :nil, :s, x::text`, db.Args{"s": "a", "n": 3, "b": true, "nil": nil}) + if err != nil { + t.Fatal(err) + } + if len(ps) != 4 { + t.Fatalf("params = %d (each name once, casts skipped)", len(ps)) + } + kinds := map[string]string{} + for _, p := range ps { + switch p.Value.(type) { + case *types.FieldMemberStringValue: + kinds[*p.Name] = "string" + case *types.FieldMemberLongValue: + kinds[*p.Name] = "long" + case *types.FieldMemberBooleanValue: + kinds[*p.Name] = "bool" + case *types.FieldMemberIsNull: + kinds[*p.Name] = "null" + } + } + want := map[string]string{"s": "string", "n": "long", "b": "bool", "nil": "null"} + for k, v := range want { + if kinds[k] != v { + t.Errorf("%s = %s, want %s", k, kinds[k], v) + } + } + if _, err := parameters(`select :x`, db.Args{"x": []string{"no"}}); err == nil { + t.Fatal("slices must go through db.TextArray") + } +} + +func TestTranslate(t *testing.T) { + err := translate(&types.BadRequestException{Message: aws.String( + `ERROR: duplicate key value violates unique constraint "users_email_mode_key"; SQLState: 23505`)}) + if name, ok := db.UniqueViolation(err); !ok || name != "users_email_mode_key" { + t.Fatalf("translate = %v", err) + } + other := errors.New("boom") + if translate(other) != other { + t.Fatal("non-database errors pass through") + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..76ade15 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,267 @@ +// Package db is the one seam between business logic and the database. +// +// Production talks to Aurora Serverless v2 through the RDS Data API; +// laptops and CI talk to plain Postgres through pgx (see README → The two +// database backends). Both drivers implement the same tiny interface, and +// all SQL is written once against it: +// +// - Parameters are named (:user_id) and are only ever scalars: nil, +// string, bool, int64, or float64. Arrays and JSON travel as text and +// are cast in SQL (:perms::text[], :payload::jsonb, :at::timestamptz), +// because the Data API has no array parameters and its typed +// parameter mapping differs from pgx's. +// - Every query that returns rows selects exactly one json/jsonb column, +// built by Postgres itself (to_jsonb(t), jsonb_build_object(...)). Both +// drivers hand that back as raw JSON text and callers decode it with +// encoding/json. That sidesteps the Data API's per-type field mapping +// entirely: arrays, timestamps, and jsonb all arrive as JSON. +// - Every unit of work runs inside Tx, which first sets the +// transaction-local settings Row-Level Security reads +// (app.current_test_mode). Outside a transaction there is no RLS +// context, so the interface doesn't offer one. +package db + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + "time" +) + +// Args are named statement parameters. Values must be nil, string, bool, +// int64, int, or float64; use the helpers below for everything else. +type Args map[string]any + +// Querier runs statements inside a transaction. +type Querier interface { + // Query runs a statement that selects exactly one json/jsonb column and + // returns each row's JSON text. + Query(ctx context.Context, sql string, args Args) ([]json.RawMessage, error) + // Exec runs a statement and returns the number of rows affected. + Exec(ctx context.Context, sql string, args Args) (int64, error) +} + +// Settings are the transaction-local values RLS policies read. +type Settings struct { + TestMode bool + // AllModes lets scheduled jobs see test and live rows together. + // Never set on a request path. + AllModes bool + // AuditMaintenance lets the archival and erasure jobs update or delete + // audit_events, which is otherwise append-only at the database. + AuditMaintenance bool +} + +// ModeSetting is the value of app.current_test_mode for s. +func (s Settings) ModeSetting() string { + switch { + case s.AllModes: + return "any" + case s.TestMode: + return "true" + default: + return "false" + } +} + +// SetupSQL is the statement each driver runs first in every transaction. +const SetupSQL = `select set_config('app.current_test_mode', :tm, true), set_config('app.audit_maintenance', :am, true)` + +// SetupArgs are SetupSQL's parameters for s. +func (s Settings) SetupArgs() Args { + am := "off" + if s.AuditMaintenance { + am = "on" + } + return Args{"tm": s.ModeSetting(), "am": am} +} + +// DB opens transactions. +type DB interface { + // Tx runs fn in a transaction. fn's error rolls it back; nil commits. + Tx(ctx context.Context, s Settings, fn func(q Querier) error) error + // ExecScript runs statements verbatim (no parameter parsing) in one + // transaction. Used by migrations. + ExecScript(ctx context.Context, statements []string) error + // Autocommit runs single statements outside any transaction and + // without RLS settings. Only for tables with no RLS policy (rate-limit + // counters), where a write must survive the request's own rollback. + Autocommit() Querier + Close() +} + +// ErrNotFound is returned by One when no row matched. +var ErrNotFound = errors.New("db: no rows") + +// One decodes the first row into dst, or returns ErrNotFound. +func One(ctx context.Context, q Querier, dst any, sql string, args Args) error { + rows, err := q.Query(ctx, sql, args) + if err != nil { + return err + } + if len(rows) == 0 { + return ErrNotFound + } + return json.Unmarshal(rows[0], dst) +} + +// All decodes every row into a slice of T. +func All[T any](ctx context.Context, q Querier, sql string, args Args) ([]T, error) { + rows, err := q.Query(ctx, sql, args) + if err != nil { + return nil, err + } + out := make([]T, 0, len(rows)) + for _, r := range rows { + var v T + if err := json.Unmarshal(r, &v); err != nil { + return nil, fmt.Errorf("db: decode row: %w", err) + } + out = append(out, v) + } + return out, nil +} + +// Time encodes a timestamp parameter; cast it with ::timestamptz in SQL. +func Time(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) } + +// NullTime encodes an optional timestamp (nil → SQL null). +func NullTime(t *time.Time) any { + if t == nil { + return nil + } + return Time(*t) +} + +// NullString encodes an optional string (nil → SQL null). +func NullString(s *string) any { + if s == nil { + return nil + } + return *s +} + +// JSON encodes a value as JSON text; cast it with ::jsonb in SQL. +func JSON(v any) string { + b, err := json.Marshal(v) + if err != nil { + panic(fmt.Sprintf("db.JSON: %v", err)) + } + return string(b) +} + +// TextArray encodes a []string as a Postgres array literal; cast it with +// ::text[] in SQL. nil and empty both encode as '{}'. +func TextArray(ss []string) string { + var b strings.Builder + b.WriteByte('{') + for i, s := range ss { + if i > 0 { + b.WriteByte(',') + } + b.WriteByte('"') + for _, r := range s { + if r == '"' || r == '\\' { + b.WriteByte('\\') + } + b.WriteRune(r) + } + b.WriteByte('"') + } + b.WriteByte('}') + return b.String() +} + +// NullTextArray encodes an optional []string (nil → SQL null). +func NullTextArray(ss []string) any { + if ss == nil { + return nil + } + return TextArray(ss) +} + +var namedParam = regexp.MustCompile(`(::?)([a-zA-Z_][a-zA-Z0-9_]*)`) + +// Positional rewrites :name parameters to $1..$n for drivers that need +// positional placeholders, leaving ::casts untouched. It returns the +// rewritten SQL and the ordered argument values. +func Positional(sql string, args Args) (string, []any, error) { + index := map[string]int{} + var ordered []any + var missing []string + out := namedParam.ReplaceAllStringFunc(sql, func(m string) string { + if strings.HasPrefix(m, "::") { + return m + } + name := m[1:] + if i, ok := index[name]; ok { + return fmt.Sprintf("$%d", i) + } + v, ok := args[name] + if !ok { + missing = append(missing, name) + return m + } + ordered = append(ordered, normalize(v)) + index[name] = len(ordered) + return fmt.Sprintf("$%d", len(ordered)) + }) + if len(missing) > 0 { + return "", nil, fmt.Errorf("db: missing parameters %v", missing) + } + return out, ordered, nil +} + +// normalize narrows the accepted Go types so both drivers see the same set. +func normalize(v any) any { + switch t := v.(type) { + case int: + return int64(t) + case int32: + return int64(t) + case *string: + return NullString(t) + case time.Time: + return Time(t) + case *time.Time: + return NullTime(t) + default: + return v + } +} + +// Normalize is normalize, exported for drivers outside this package. +func Normalize(v any) any { return normalize(v) } + +// UniqueViolation reports whether err is a Postgres unique-constraint +// violation, and if so which constraint (empty when the driver can't say). +func UniqueViolation(err error) (constraint string, ok bool) { + var ce *ConstraintError + if errors.As(err, &ce) && ce.Code == "23505" { + return ce.Constraint, true + } + return "", false +} + +// ConstraintError is the driver-neutral form of a Postgres integrity error. +type ConstraintError struct { + Code string // SQLSTATE, e.g. 23505 unique_violation, 23514 check_violation + Constraint string + Message string +} + +func (e *ConstraintError) Error() string { + return fmt.Sprintf("db: %s (%s): %s", e.Code, e.Constraint, e.Message) +} + +// CheckViolation reports whether err is a check/FK/not-null violation. +func CheckViolation(err error) (*ConstraintError, bool) { + var ce *ConstraintError + if errors.As(err, &ce) && strings.HasPrefix(ce.Code, "23") && ce.Code != "23505" { + return ce, true + } + return nil, false +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..98f44bc --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,35 @@ +package db + +import ( + "reflect" + "testing" +) + +func TestPositional(t *testing.T) { + sql, args, err := Positional(`select :a, :b::timestamptz, :a, x::text, '{"k": 1}' from t where y = :b`, Args{"a": "1", "b": 2}) + if err != nil { + t.Fatal(err) + } + want := `select $1, $2::timestamptz, $1, x::text, '{"k": 1}' from t where y = $2` + if sql != want { + t.Fatalf("sql = %s", sql) + } + if !reflect.DeepEqual(args, []any{"1", int64(2)}) { + t.Fatalf("args = %v", args) + } + if _, _, err := Positional(`select :missing`, Args{}); err == nil { + t.Fatal("expected a missing-parameter error") + } +} + +func TestTextArray(t *testing.T) { + cases := map[string][]string{`{}`: nil, `{"a","b c"}`: {"a", "b c"}, `{"q\"uote","back\\slash"}`: {`q"uote`, `back\slash`}} + for want, in := range cases { + if got := TextArray(in); got != want { + t.Errorf("TextArray(%q) = %s, want %s", in, got, want) + } + } + if NullTextArray(nil) != nil { + t.Error("nil should encode as SQL null") + } +} diff --git a/internal/db/migrate/migrate.go b/internal/db/migrate/migrate.go new file mode 100644 index 0000000..b625154 --- /dev/null +++ b/internal/db/migrate/migrate.go @@ -0,0 +1,187 @@ +// Package migrate applies the numbered SQL files in migrations/, in order, +// tracked in schema_migrations. The same runner and the same files run +// against local Postgres (pgx) and Aurora (Data API); see README → +// Technology stack for why this isn't a third-party tool. +// +// Migrations are forward-only, and each file applies in one transaction. +// Statements are split here, rather than sent as one script, because the +// Data API executes exactly one statement per call. +package migrate + +import ( + "context" + "encoding/json" + "fmt" + "io/fs" + "path" + "regexp" + "sort" + "strings" + + "github.com/Adron/substratalapps.com/internal/db" +) + +var fileName = regexp.MustCompile(`^(\d{4})_[a-z0-9_]+\.sql$`) + +// Migration is one numbered file. +type Migration struct { + Version string // "0001" + Name string // "0001_init.sql" + SQL string +} + +// Load reads every migration from fsys (the migrations directory), sorted. +func Load(fsys fs.FS) ([]Migration, error) { + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + return nil, err + } + var out []Migration + seen := map[string]string{} + for _, e := range entries { + if e.IsDir() || path.Ext(e.Name()) != ".sql" { + continue + } + m := fileName.FindStringSubmatch(e.Name()) + if m == nil { + return nil, fmt.Errorf("migrate: %s doesn't match NNNN_name.sql", e.Name()) + } + if prev, dup := seen[m[1]]; dup { + return nil, fmt.Errorf("migrate: %s and %s share version %s", prev, e.Name(), m[1]) + } + seen[m[1]] = e.Name() + b, err := fs.ReadFile(fsys, e.Name()) + if err != nil { + return nil, err + } + out = append(out, Migration{Version: m[1], Name: e.Name(), SQL: string(b)}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version }) + return out, nil +} + +// Up applies every migration not yet recorded. It returns the names applied. +func Up(ctx context.Context, d db.DB, migrations []Migration) ([]string, error) { + if err := d.ExecScript(ctx, []string{`create table if not exists schema_migrations ( + version text primary key, + name text not null, + applied_at timestamptz not null default now() + )`}); err != nil { + return nil, err + } + var applied map[string]bool + err := d.Tx(ctx, db.Settings{AllModes: true}, func(q db.Querier) error { + rows, err := q.Query(ctx, `select to_jsonb(version) from schema_migrations`, nil) + if err != nil { + return err + } + applied = make(map[string]bool, len(rows)) + for _, r := range rows { + var v string + if err := json.Unmarshal(r, &v); err != nil { + return err + } + applied[v] = true + } + return nil + }) + if err != nil { + return nil, err + } + var done []string + for _, m := range migrations { + if applied[m.Version] { + continue + } + stmts := Split(m.SQL) + stmts = append(stmts, fmt.Sprintf(`insert into schema_migrations (version, name) values ('%s', '%s')`, + m.Version, strings.ReplaceAll(m.Name, "'", "''"))) + if err := d.ExecScript(ctx, stmts); err != nil { + return done, fmt.Errorf("migrate: %s: %w", m.Name, err) + } + done = append(done, m.Name) + } + return done, nil +} + +// Split breaks a SQL script into statements on top-level semicolons, +// respecting quotes, comments, and dollar-quoted bodies. +func Split(script string) []string { + var out []string + var cur strings.Builder + flush := func() { + s := strings.TrimSpace(cur.String()) + if s != "" && !onlyComments(s) { + out = append(out, s) + } + cur.Reset() + } + for i := 0; i < len(script); i++ { + c := script[i] + switch { + case c == '-' && i+1 < len(script) && script[i+1] == '-': + end := strings.IndexByte(script[i:], '\n') + if end < 0 { + end = len(script) - i + } + cur.WriteString(script[i : i+end]) + i += end - 1 + case c == '/' && i+1 < len(script) && script[i+1] == '*': + end := strings.Index(script[i+2:], "*/") + if end < 0 { + end = len(script) - i - 2 + } + cur.WriteString(script[i : i+2+end+2]) + i += 2 + end + 1 + case c == '\'' || c == '"': + j := i + 1 + for j < len(script) { + if script[j] == c { + if j+1 < len(script) && script[j+1] == c { // doubled quote escape + j += 2 + continue + } + break + } + j++ + } + cur.WriteString(script[i:min(j+1, len(script))]) + i = j + case c == '$': + tag := dollarTag(script[i:]) + if tag == "" { + cur.WriteByte(c) + continue + } + end := strings.Index(script[i+len(tag):], tag) + if end < 0 { + cur.WriteString(script[i:]) + i = len(script) + continue + } + stop := i + len(tag) + end + len(tag) + cur.WriteString(script[i:stop]) + i = stop - 1 + case c == ';': + flush() + default: + cur.WriteByte(c) + } + } + flush() + return out +} + +var dollarTagRe = regexp.MustCompile(`^\$[A-Za-z_]*\$`) + +func dollarTag(s string) string { return dollarTagRe.FindString(s) } + +func onlyComments(s string) bool { + for _, line := range strings.Split(s, "\n") { + t := strings.TrimSpace(line) + if t != "" && !strings.HasPrefix(t, "--") { + return false + } + } + return true +} diff --git a/internal/db/migrate/migrate_test.go b/internal/db/migrate/migrate_test.go new file mode 100644 index 0000000..543584f --- /dev/null +++ b/internal/db/migrate/migrate_test.go @@ -0,0 +1,35 @@ +package migrate + +import ( + "testing" + "testing/fstest" +) + +func TestSplit(t *testing.T) { + script := `-- comment; with a semicolon +create table a (x text default 'a;b'); +create function f() returns trigger language plpgsql as $$ +begin + perform 1; -- inside a body + return new; +end $$; +/* block; comment */ insert into a values ('it''s; fine'); +` + got := Split(script) + if len(got) != 3 { + t.Fatalf("got %d statements: %q", len(got), got) + } +} + +func TestLoadRejectsBadNames(t *testing.T) { + if _, err := Load(fstest.MapFS{"0001_ok.sql": {Data: []byte("select 1")}, "bad.sql": {Data: []byte("x")}}); err == nil { + t.Fatal("expected an error for bad.sql") + } + if _, err := Load(fstest.MapFS{"0001_a.sql": {}, "0001_b.sql": {}}); err == nil { + t.Fatal("expected an error for a duplicate version") + } + ms, err := Load(fstest.MapFS{"0002_b.sql": {}, "0001_a.sql": {}}) + if err != nil || ms[0].Version != "0001" { + t.Fatalf("order = %v, %v", ms, err) + } +} diff --git a/internal/db/pg/pg.go b/internal/db/pg/pg.go new file mode 100644 index 0000000..10030a1 --- /dev/null +++ b/internal/db/pg/pg.go @@ -0,0 +1,130 @@ +// Package pg implements db.DB over a direct Postgres connection with pgx. +// It's what laptops and CI run; production uses the Data API driver. +package pg + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Adron/substratalapps.com/internal/db" +) + +// DB is a pgx connection pool. +type DB struct{ pool *pgxpool.Pool } + +// Open connects to url (a postgres:// connection string). +func Open(ctx context.Context, url string) (*DB, error) { + cfg, err := pgxpool.ParseConfig(url) + if err != nil { + return nil, err + } + // Arguments are always strings/scalars cast in SQL, so simple protocol + // keeps pgx from inferring types differently than the Data API would. + cfg.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, err + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + return &DB{pool: pool}, nil +} + +// Close closes the pool. +func (d *DB) Close() { d.pool.Close() } + +// Tx implements db.DB. +func (d *DB) Tx(ctx context.Context, s db.Settings, fn func(db.Querier) error) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck // no-op after commit + q := querier{tx: tx} + if _, err := q.Query(ctx, `select to_jsonb(x) from (`+db.SetupSQL+`) x`, s.SetupArgs()); err != nil { + return err + } + if err := fn(q); err != nil { + return err + } + return tx.Commit(ctx) +} + +// ExecScript implements db.DB. +func (d *DB) ExecScript(ctx context.Context, statements []string) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck // no-op after commit + for _, st := range statements { + if _, err := tx.Exec(ctx, st); err != nil { + return fmt.Errorf("%w\n--- statement ---\n%s", translate(err), st) + } + } + return tx.Commit(ctx) +} + +// Autocommit implements db.DB. +func (d *DB) Autocommit() db.Querier { return querier{tx: d.pool} } + +// execer is what querier needs: a pgx.Tx or the pool itself (autocommit). +type execer interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) +} + +type querier struct{ tx execer } + +func (q querier) Query(ctx context.Context, sql string, args db.Args) ([]json.RawMessage, error) { + stmt, vals, err := db.Positional(sql, args) + if err != nil { + return nil, err + } + rows, err := q.tx.Query(ctx, stmt, vals...) + if err != nil { + return nil, translate(err) + } + defer rows.Close() + var out []json.RawMessage + for rows.Next() { + var s *string + if err := rows.Scan(&s); err != nil { + return nil, fmt.Errorf("pg: scan (query must select one json column): %w", err) + } + if s == nil { + out = append(out, json.RawMessage("null")) + continue + } + out = append(out, json.RawMessage(*s)) + } + return out, translate(rows.Err()) +} + +func (q querier) Exec(ctx context.Context, sql string, args db.Args) (int64, error) { + stmt, vals, err := db.Positional(sql, args) + if err != nil { + return 0, err + } + tag, err := q.tx.Exec(ctx, stmt, vals...) + if err != nil { + return 0, translate(err) + } + return tag.RowsAffected(), nil +} + +func translate(err error) error { + var pe *pgconn.PgError + if errors.As(err, &pe) && len(pe.Code) == 5 && pe.Code[:2] == "23" { + return &db.ConstraintError{Code: pe.Code, Constraint: pe.ConstraintName, Message: pe.Message} + } + return err +} diff --git a/internal/email/email.go b/internal/email/email.go new file mode 100644 index 0000000..62d33b5 --- /dev/null +++ b/internal/email/email.go @@ -0,0 +1,120 @@ +// Package email sends transactional email (Auth → Email verification): +// SES in AWS, SMTP to Mailpit locally, or an in-memory capture in tests. +package email + +import ( + "context" + "fmt" + "log/slog" + "net/mail" + "net/smtp" + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/service/sesv2" + sestypes "github.com/aws/aws-sdk-go-v2/service/sesv2/types" +) + +// Message is one plain-text email. +type Message struct { + To string + FromName string // display name override, e.g. an Application's email_from_name + Subject string + Text string + // Kind tags the message for tests and logs (verify, reset, invitation, …). + Kind string +} + +// Sender delivers messages. +type Sender interface { + Send(ctx context.Context, m Message) error +} + +func fromHeader(defaultFrom, name string) string { + if name == "" { + return defaultFrom + } + addr, err := mail.ParseAddress(defaultFrom) + if err != nil { + return defaultFrom + } + return (&mail.Address{Name: name, Address: addr.Address}).String() +} + +// SMTP sends through a plain SMTP server (Mailpit locally; no auth, no TLS). +type SMTP struct { + Addr string + From string +} + +func (s SMTP) Send(_ context.Context, m Message) error { + from := fromHeader(s.From, m.FromName) + envelopeFrom := from + if a, err := mail.ParseAddress(from); err == nil { + envelopeFrom = a.Address + } + var b strings.Builder + fmt.Fprintf(&b, "From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nX-Substratal-Kind: %s\r\n\r\n%s", + from, m.To, m.Subject, time.Now().UTC().Format(time.RFC1123Z), m.Kind, m.Text) + return smtp.SendMail(s.Addr, nil, envelopeFrom, []string{m.To}, []byte(b.String())) +} + +// SES sends through Amazon SES v2. +type SES struct { + Client *sesv2.Client + From string +} + +func (s SES) Send(ctx context.Context, m Message) error { + from := fromHeader(s.From, m.FromName) + _, err := s.Client.SendEmail(ctx, &sesv2.SendEmailInput{ + FromEmailAddress: &from, + Destination: &sestypes.Destination{ToAddresses: []string{m.To}}, + Content: &sestypes.EmailContent{Simple: &sestypes.Message{ + Subject: &sestypes.Content{Data: &m.Subject}, + Body: &sestypes.Body{Text: &sestypes.Content{Data: &m.Text}}, + }}, + }) + return err +} + +// Log writes messages to the structured log instead of sending them. +type Log struct{} + +func (Log) Send(_ context.Context, m Message) error { + slog.Info("email (log backend)", "to", m.To, "kind", m.Kind, "subject", m.Subject) + return nil +} + +// Capture records messages in memory (tests). +type Capture struct { + mu sync.Mutex + sent []Message +} + +func (c *Capture) Send(_ context.Context, m Message) error { + c.mu.Lock() + defer c.mu.Unlock() + c.sent = append(c.sent, m) + return nil +} + +// Sent returns every message so far. +func (c *Capture) Sent() []Message { + c.mu.Lock() + defer c.mu.Unlock() + return append([]Message(nil), c.sent...) +} + +// Last returns the most recent message to `to` of `kind`, if any. +func (c *Capture) Last(to, kind string) (Message, bool) { + c.mu.Lock() + defer c.mu.Unlock() + for i := len(c.sent) - 1; i >= 0; i-- { + if strings.EqualFold(c.sent[i].To, to) && (kind == "" || c.sent[i].Kind == kind) { + return c.sent[i], true + } + } + return Message{}, false +} diff --git a/internal/httpx/cursor.go b/internal/httpx/cursor.go new file mode 100644 index 0000000..4a15d46 --- /dev/null +++ b/internal/httpx/cursor.go @@ -0,0 +1,143 @@ +package httpx + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +// Page is the list envelope's page object. +type Page struct { + NextCursor *string `json:"next_cursor"` + HasMore bool `json:"has_more"` +} + +// List is the list envelope (Conventions → Pagination). +type List[T any] struct { + Data []T `json:"data"` + Page Page `json:"page"` +} + +// Cursors signs and verifies opaque pagination cursors. A cursor binds the +// position (sort key + id), the filters it was issued under, and an expiry, +// so a tampered, expired, or filter-mismatched cursor is invalid_cursor. +type Cursors struct{ Key []byte } + +// CursorTTL is how long a cursor stays valid. +const CursorTTL = 24 * time.Hour + +type cursorBody struct { + Sort string `json:"s"` // the sort key of the last row returned (timestamp, RFC3339Nano) + ID string `json:"i"` // its id, the tie-breaker + F string `json:"f"` // filter fingerprint + Exp int64 `json:"e"` +} + +// Position is a decoded cursor: rows strictly after (Sort, ID) in the +// list's newest-first order. +type Position struct { + Sort string + ID string +} + +// Encode returns a cursor for the row after which the next page starts. +func (c Cursors) Encode(sortKey, id, filters string) string { + b, _ := json.Marshal(cursorBody{Sort: sortKey, ID: id, F: filters, Exp: time.Now().Add(CursorTTL).Unix()}) + payload := base64.RawURLEncoding.EncodeToString(b) + mac := hmac.New(sha256.New, c.Key) + mac.Write([]byte(payload)) + return payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16]) +} + +// Decode verifies a cursor. Empty input means "first page". +func (c Cursors) Decode(cursor, filters string) (*Position, error) { + if cursor == "" { + return nil, nil + } + payload, sig, ok := strings.Cut(cursor, ".") + if !ok { + return nil, ErrInvalidCursor() + } + mac := hmac.New(sha256.New, c.Key) + mac.Write([]byte(payload)) + want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16]) + if !hmac.Equal([]byte(sig), []byte(want)) { + return nil, ErrInvalidCursor() + } + raw, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + return nil, ErrInvalidCursor() + } + var b cursorBody + if err := json.Unmarshal(raw, &b); err != nil || b.F != filters || time.Now().Unix() > b.Exp { + return nil, ErrInvalidCursor() + } + return &Position{Sort: b.Sort, ID: b.ID}, nil +} + +// Limit clamps a requested page size to 1–100, default 25. +func Limit(p *int) int { + if p == nil || *p < 1 { + if p != nil && *p < 1 { + return 1 + } + return 25 + } + if *p > 100 { + return 100 + } + return *p +} + +// Filters fingerprints a request's filter parameters (everything but +// cursor and limit), so a cursor is only valid under the filters it came +// from. +func Filters(r *http.Request, exclude ...string) string { + q := r.URL.Query() + skip := map[string]bool{"cursor": true, "limit": true} + for _, e := range exclude { + skip[e] = true + } + var keys []string + for k := range q { + if !skip[k] { + keys = append(keys, k) + } + } + sort.Strings(keys) + var b strings.Builder + b.WriteString(r.URL.Path) + for _, k := range keys { + b.WriteString("&" + k + "=" + strings.Join(q[k], ",")) + } + sum := sha256.Sum256([]byte(b.String())) + return base64.RawURLEncoding.EncodeToString(sum[:12]) +} + +// Paginate trims a limit+1 result to limit rows and builds the page +// object. key returns a row's (sort key, id). +func Paginate[T any](c Cursors, rows []T, limit int, filters string, key func(T) (string, string)) List[T] { + out := List[T]{Data: rows} + if out.Data == nil { + out.Data = []T{} + } + if len(rows) > limit { + out.Data = rows[:limit] + s, id := key(rows[limit-1]) + cur := c.Encode(s, id, filters) + out.Page = Page{NextCursor: &cur, HasMore: true} + } + return out +} + +// TimeKey formats a timestamp as a cursor sort key. +func TimeKey(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) } + +// IntKey formats an integer sort key. +func IntKey(n int64) string { return strconv.FormatInt(n, 10) } diff --git a/internal/httpx/errors.go b/internal/httpx/errors.go new file mode 100644 index 0000000..ec549ca --- /dev/null +++ b/internal/httpx/errors.go @@ -0,0 +1,154 @@ +// Package httpx implements the cross-cutting request/response rules from +// API Reference → Conventions: the error envelope, JSON bodies, PATCH +// presence, pagination cursors, ETag/If-Match, and request ids. Every +// handler gets these from here rather than reimplementing them. +package httpx + +import ( + "fmt" + "net/http" + "strconv" +) + +// Error is an API error: a stable code, an HTTP status, a human message, +// and optional details. It renders as {"error": {code, message, details}}. +type Error struct { + Status int + Code string + Message string + Details map[string]any + // Headers are extra response headers (Retry-After, …). + Headers map[string]string +} + +func (e *Error) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) } + +// E builds an Error. +func E(status int, code, message string) *Error { + return &Error{Status: status, Code: code, Message: message} +} + +// With adds a details key. +func (e *Error) With(key string, value any) *Error { + if e.Details == nil { + e.Details = map[string]any{} + } + e.Details[key] = value + return e +} + +// RetryAfter sets the Retry-After header, in seconds. +func (e *Error) RetryAfter(seconds int) *Error { + if e.Headers == nil { + e.Headers = map[string]string{} + } + e.Headers["Retry-After"] = strconv.Itoa(seconds) + return e +} + +// Cross-cutting errors (Conventions → Errors). +var ( + ErrInvalidCursor = func() *Error { + return E(400, "invalid_cursor", "The pagination cursor is expired, invalid, or was issued for different filters.") + } + ErrUserTokenRequired = func() *Error { + return E(400, "user_token_required", "This endpoint acts as a User and can't be called with an API Key.") + } + ErrIdempotencyRequired = func() *Error { + return E(400, "idempotency_key_required", "This request requires an Idempotency-Key header.") + } + ErrUnauthenticated = func() *Error { return E(401, "unauthenticated", "Missing, malformed, or expired credentials.") } + ErrSessionRevoked = func() *Error { return E(401, "session_revoked", "This session has been logged out or revoked.") } + ErrNotFound = func() *Error { return E(404, "not_found", "No route matches this path.") } + ErrPayloadTooLarge = func() *Error { return E(413, "payload_too_large", "Request body exceeds 1 MB.") } + ErrInternal = func() *Error { + return E(500, "internal_error", "Unexpected server error. Quote the X-Request-Id to support.") + } +) + +// Invalid is 400 invalid_request. +func Invalid(format string, a ...any) *Error { + return E(400, "invalid_request", fmt.Sprintf(format, a...)) +} + +// Forbidden is 403 forbidden naming the missing permission. +func Forbidden(permission string) *Error { + e := E(403, "forbidden", "The caller lacks the permission this request requires.") + if permission != "" { + e = e.With("required_permission", permission) + } + return e +} + +// NotFoundResource is 404 _not_found. +func NotFoundResource(resource string) *Error { + return E(404, resource+"_not_found", "No "+humanize(resource)+" with this id exists, or it isn't visible to the caller.") +} + +// Conflict is a 409 with a resource-specific code. +func Conflict(code, message string) *Error { return E(409, code, message) } + +// VersionConflict is 409 version_conflict with the current ETag. +func VersionConflict(currentVersion int) *Error { + return E(409, "version_conflict", "The resource changed since the If-Match ETag was read."). + With("current_etag", ETag(currentVersion)) +} + +// FieldError is one entry of details.fields on a 422 validation_failed. +type FieldError struct { + Field string `json:"field"` + Code string `json:"code"` + Min *int `json:"min,omitempty"` + Max *int `json:"max,omitempty"` + Allowed []string `json:"allowed,omitempty"` + Pattern string `json:"pattern,omitempty"` + Message string `json:"message,omitempty"` +} + +// Fields accumulates field errors for one request. +type Fields []FieldError + +// Add records a field error. +func (f *Fields) Add(field, code string) *FieldError { + *f = append(*f, FieldError{Field: field, Code: code}) + return &(*f)[len(*f)-1] +} + +// Max records a too_long / out_of_range error with its bound. +func (f *Fields) Max(field, code string, max int) { + f.Add(field, code).Max = &max +} + +// Enum records an enum_mismatch with the allowed values. +func (f *Fields) Enum(field string, allowed ...string) { + f.Add(field, "enum_mismatch").Allowed = allowed +} + +// Err returns the 422, or nil when there were no field errors. One field +// error and many both use details.fields here; the array form is what +// clients parse, so it's always present on validation_failed. +func (f Fields) Err() error { + if len(f) == 0 { + return nil + } + msg := "1 field failed validation." + if len(f) > 1 { + msg = fmt.Sprintf("%d fields failed validation.", len(f)) + } + return E(http.StatusUnprocessableEntity, "validation_failed", msg).With("fields", []FieldError(f)) +} + +// ReadOnly is 422 read_only_field. +func ReadOnly(field string) *Error { + return E(422, "read_only_field", "Read-only fields can't be sent in a PATCH body.").With("field", field) +} + +func humanize(s string) string { + b := []byte(s) + for i, c := range b { + if c == '_' { + b[i] = ' ' + } + } + return string(b) +} diff --git a/internal/httpx/httpx_test.go b/internal/httpx/httpx_test.go new file mode 100644 index 0000000..a819dfa --- /dev/null +++ b/internal/httpx/httpx_test.go @@ -0,0 +1,53 @@ +package httpx + +import ( + "net/http/httptest" + "testing" +) + +func TestCursors(t *testing.T) { + c := Cursors{Key: []byte("k")} + cur := c.Encode("2026-10-05T00:00:00Z", "usr_1", "f1") + pos, err := c.Decode(cur, "f1") + if err != nil || pos.ID != "usr_1" { + t.Fatalf("decode = %v, %v", pos, err) + } + if _, err := c.Decode(cur, "f2"); err == nil { + t.Fatal("a cursor is only valid with its own filters") + } + if _, err := c.Decode(cur[:len(cur)-2]+"xx", "f1"); err == nil { + t.Fatal("a tampered cursor is invalid") + } + if _, err := (Cursors{Key: []byte("other")}).Decode(cur, "f1"); err == nil { + t.Fatal("a cursor from another key is invalid") + } +} + +func TestLimitAndFilters(t *testing.T) { + for in, want := range map[int]int{0: 1, 25: 25, 500: 100} { + v := in + if got := Limit(&v); got != want { + t.Errorf("Limit(%d) = %d", in, got) + } + } + if Limit(nil) != 25 { + t.Error("default limit is 25") + } + a := Filters(httptest.NewRequest("GET", "/v1/users?status=active&limit=5&cursor=x", nil)) + b := Filters(httptest.NewRequest("GET", "/v1/users?status=active", nil)) + c := Filters(httptest.NewRequest("GET", "/v1/users?status=invited", nil)) + if a != b || a == c { + t.Fatal("filters ignore limit/cursor and nothing else") + } +} + +func TestIfMatch(t *testing.T) { + r := httptest.NewRequest("PATCH", "/", nil) + if CheckIfMatch(r, 3) != nil { + t.Fatal("absent If-Match is last-write-wins") + } + r.Header.Set("If-Match", `W/"3"`) + if CheckIfMatch(r, 3) != nil || CheckIfMatch(r, 4) == nil { + t.Fatal("If-Match comparison") + } +} diff --git a/internal/httpx/json.go b/internal/httpx/json.go new file mode 100644 index 0000000..c00f0f9 --- /dev/null +++ b/internal/httpx/json.go @@ -0,0 +1,204 @@ +package httpx + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +// MaxBody is the request body limit (Conventions → Size and length limits). +const MaxBody = 1 << 20 + +// ReadBody reads the request body, enforcing the 1 MB limit. +func ReadBody(r *http.Request) ([]byte, error) { + if r.Body == nil { + return nil, nil + } + b, err := io.ReadAll(io.LimitReader(r.Body, MaxBody+1)) + if err != nil { + return nil, Invalid("Couldn't read the request body.") + } + if len(b) > MaxBody { + return nil, ErrPayloadTooLarge() + } + return b, nil +} + +// Decode reads a JSON object body into dst. An empty body decodes as {}. +// Unknown fields are ignored (additive changes stay non-breaking). +func Decode(r *http.Request, dst any) error { + b, err := ReadBody(r) + if err != nil { + return err + } + return DecodeBytes(b, dst) +} + +// DecodeBytes is Decode over an already-read body. +func DecodeBytes(b []byte, dst any) error { + if len(bytes.TrimSpace(b)) == 0 { + b = []byte("{}") + } + if err := json.Unmarshal(b, dst); err != nil { + var te *json.UnmarshalTypeError + if errors.As(err, &te) && te.Field != "" { + var f Fields + f.Add(te.Field, "invalid_format").Message = "expected " + te.Type.String() + return f.Err() + } + return Invalid("The request body isn't valid JSON.") + } + return nil +} + +// JSON writes a 2xx JSON response. +func JSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) +} + +// JSONWithETag writes a single-resource response with its ETag. +func JSONWithETag(w http.ResponseWriter, status int, version int, v any) { + w.Header().Set("ETag", ETag(version)) + JSON(w, status, v) +} + +// NoContent writes 204. +func NoContent(w http.ResponseWriter) { w.WriteHeader(http.StatusNoContent) } + +// WriteError renders err. Non-*Error values become 500 internal_error; +// the caller is responsible for logging those. +func WriteError(w http.ResponseWriter, err error) { + var e *Error + if !errors.As(err, &e) { + e = ErrInternal() + } + for k, v := range e.Headers { + w.Header().Set(k, v) + } + body := map[string]any{"code": e.Code, "message": e.Message} + if len(e.Details) > 0 { + body["details"] = e.Details + } + JSON(w, e.Status, map[string]any{"error": body}) +} + +// ETag is the weak validator over a row's version counter. +func ETag(version int) string { return `W/"` + strconv.Itoa(version) + `"` } + +// IfMatch parses If-Match into a version. ok is false when the header is +// absent (last-write-wins). A malformed header never matches any version. +func IfMatch(r *http.Request) (version int, ok bool) { + h := strings.TrimSpace(r.Header.Get("If-Match")) + if h == "" { + return 0, false + } + h = strings.TrimPrefix(h, "W/") + h = strings.Trim(h, `"`) + v, err := strconv.Atoi(h) + if err != nil { + return -1, true + } + return v, true +} + +// CheckIfMatch returns version_conflict when If-Match is present and stale. +func CheckIfMatch(r *http.Request, current int) error { + if want, ok := IfMatch(r); ok && want != current { + return VersionConflict(current) + } + return nil +} + +// Patch is a PATCH body decoded with field presence preserved, so an +// omitted field (leave unchanged) and an explicit null (clear) differ. +type Patch map[string]json.RawMessage + +// DecodePatch reads a PATCH body. +func DecodePatch(r *http.Request) (Patch, error) { + b, err := ReadBody(r) + if err != nil { + return nil, err + } + if len(bytes.TrimSpace(b)) == 0 { + return Patch{}, nil + } + var p Patch + if err := json.Unmarshal(b, &p); err != nil { + return nil, Invalid("The request body isn't a JSON object.") + } + return p, nil +} + +// Has reports whether field was sent. +func (p Patch) Has(field string) bool { _, ok := p[field]; return ok } + +// IsNull reports whether field was sent as an explicit null. +func (p Patch) IsNull(field string) bool { + v, ok := p[field] + return ok && bytes.Equal(bytes.TrimSpace(v), []byte("null")) +} + +// Get decodes field into dst. It returns false when the field is absent. +// A type mismatch is recorded in f. +func (p Patch) Get(field string, dst any, f *Fields) bool { + v, ok := p[field] + if !ok { + return false + } + if err := json.Unmarshal(v, dst); err != nil { + f.Add(field, "invalid_format") + return false + } + return true +} + +// RejectReadOnly returns read_only_field if any of fields was sent. +func (p Patch) RejectReadOnly(fields ...string) error { + for _, f := range fields { + if p.Has(f) { + return ReadOnly(f) + } + } + return nil +} + +// RequireNonNull records a validation error for each field sent as null +// that isn't nullable (Conventions → Partial updates). +func (p Patch) RequireNonNull(f *Fields, fields ...string) { + for _, name := range fields { + if p.IsNull(name) { + f.Add(name, "required").Message = "this field isn't nullable" + } + } +} + +// String is a decoded optional-nullable string for PATCH handling. +func (p Patch) String(field string, f *Fields) (val *string, present bool) { + if !p.Has(field) { + return nil, false + } + if p.IsNull(field) { + return nil, true + } + var s string + if !p.Get(field, &s, f) { + return nil, true + } + return &s, true +} + +// Mustf panics with a formatted message; for programmer errors only. +func Mustf(cond bool, format string, a ...any) { + if !cond { + panic(fmt.Sprintf(format, a...)) + } +} diff --git a/internal/ids/ids.go b/internal/ids/ids.go new file mode 100644 index 0000000..3c56fe1 --- /dev/null +++ b/internal/ids/ids.go @@ -0,0 +1,88 @@ +// Package ids generates the prefixed resource ids and credential strings +// from Conventions → IDs: a type prefix plus a ULID (26 Crockford base32 +// characters). Application and Role ids are derived, not generated, and +// live with those resources. +package ids + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "strings" + "sync" + "time" + + "github.com/oklog/ulid/v2" +) + +// Resource id prefixes. +const ( + User = "usr_" + Organization = "org_" + Tenant = "tnt_" + TierChange = "tcr_" + Entitlement = "ent_" + AuditEvent = "evt_" + Webhook = "whk_" + APIKey = "key_" + UserIdentity = "uid_" + SSOConnection = "ssc_" + Session = "ses_" + WebhookEvent = "wev_" + Delivery = "dlv_" +) + +// Credential prefixes. Never logged, never echoed after issuance. +const ( + RefreshToken = "rtk_" + MFAChallenge = "mfa_" + AuthorizationCode = "ac_" + EmailVerify = "emv_" + PasswordReset = "pwr_" + Invitation = "inv_" + WebhookSecret = "whsec_" + APIKeyLive = "satk_live_" + APIKeyTest = "satk_test_" + AccessTokenJTI = "atk_" + AppTokenJTI = "apt_" +) + +var ( + mu sync.Mutex + entropy = ulid.Monotonic(rand.Reader, 0) +) + +// New returns prefix + a new ULID. +func New(prefix string) string { + mu.Lock() + defer mu.Unlock() + return prefix + ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String() +} + +// HasPrefix reports whether id is prefix + a well-formed ULID. +func HasPrefix(id, prefix string) bool { + if !strings.HasPrefix(id, prefix) { + return false + } + _, err := ulid.ParseStrict(id[len(prefix):]) + return err == nil +} + +// Secret returns prefix + 26 base32 characters of fresh randomness (130 +// bits), formatted like a ULID but entirely random, for bearer credentials. +func Secret(prefix string) string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + panic(err) + } + var u ulid.ULID + copy(u[:], b[:]) + return prefix + u.String() +} + +// Hash is the SHA-256 hex of a bearer credential: what's stored and looked +// up, never the credential itself (Conventions → IDs, credential strings). +func Hash(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go new file mode 100644 index 0000000..133f8c0 --- /dev/null +++ b/internal/jobs/jobs.go @@ -0,0 +1,221 @@ +// Package jobs is every scheduled job (DEPLOYMENT.md → Build checklist, +// step 6), one entry point each. EventBridge Scheduler invokes the jobs +// Lambda with {"job": ""}; locally, `make job name=` runs one. +package jobs + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + + "github.com/Adron/substratalapps.com/internal/billing" + "github.com/Adron/substratalapps.com/internal/core" + "github.com/Adron/substratalapps.com/internal/platform/wire" +) + +// Runner holds what the jobs need. +type Runner struct { + Base *wire.Base + Core *core.Server + Stripe *billing.Client // nil without Stripe configuration +} + +// New builds a Runner. +func New(base *wire.Base) *Runner { + r := &Runner{Base: base, Core: base.Server(nil)} + if base.Config.StripeSecretKey != "" { + r.Stripe = billing.New(base.Config.StripeSecretKey, base.Config.StripePrices, base.Log) + } + return r +} + +// Jobs maps a job name to its function. +func (r *Runner) Jobs() map[string]func(context.Context) error { + return map[string]func(context.Context) error{ + // every 5 minutes + "entitlement-sweep": func(ctx context.Context) error { + exp, started, err := r.Core.SweepEntitlements(ctx) + r.Base.Log.Info("entitlement sweep", "expired", exp, "started", started) + return err + }, + // every minute: the safety net under SQS nudges + "webhook-dispatch": func(ctx context.Context) error { + return r.Base.Deliverer().Run(ctx) + }, + // hourly + "erasure-cascade": func(ctx context.Context) error { + n, err := r.Core.RunErasures(ctx) + r.Base.Log.Info("erasure cascade", "completed", n) + return err + }, + "webhook-health": func(ctx context.Context) error { + n, err := r.Base.Deliverer().DisableLongUnhealthy(ctx) + r.Base.Log.Info("webhook health", "disabled", n) + return err + }, + "stripe-customers": r.stripeCustomers, + // every 10 minutes: events whose first processing failed + "stripe-events": r.stripeEvents, + // daily 00:15 UTC + "seat-sync": r.seatSync, + // nightly + "cleanup": func(ctx context.Context) error { return r.Core.Cleanup(ctx) }, + "audit-archive": func(ctx context.Context) error { + n, err := r.Core.ArchiveAudit(ctx, r.archiver()) + r.Base.Log.Info("audit archive", "archived", n) + return err + }, + "settings-projections": func(ctx context.Context) error { return r.Core.RebuildAllProjections(ctx) }, + } +} + +// Names lists the jobs, sorted. +func (r *Runner) Names() []string { + var out []string + for k := range r.Jobs() { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// Run runs one job by name. +func (r *Runner) Run(ctx context.Context, name string) error { + f, ok := r.Jobs()[name] + if !ok { + return fmt.Errorf("jobs: unknown job %q (known: %v)", name, r.Names()) + } + start := time.Now() + err := f(ctx) + r.Base.Log.Info("job finished", "job", name, "duration_ms", time.Since(start).Milliseconds(), "error", errString(err)) + return err +} + +func errString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func (r *Runner) stripeCustomers(ctx context.Context) error { + if r.Stripe == nil { + return nil + } + ids, err := r.Core.TenantsMissingCustomer(ctx) + if err != nil { + return err + } + for _, id := range ids { + r.Core.ProvisionStripeCustomer(ctx, id) + } + return nil +} + +// stripeEvents reprocesses recorded events that failed the first time, by +// re-fetching each from Stripe (processing is idempotent: it always reads +// the Subscription's current state). +func (r *Runner) stripeEvents(ctx context.Context) error { + if r.Stripe == nil { + return nil + } + ids, err := r.Core.UnprocessedStripeEvents(ctx) + if err != nil { + return err + } + wh := &billing.Webhook{Stripe: r.Stripe, Core: r.Core, Log: r.Base.Log} + for _, id := range ids { + ev, err := r.Stripe.Event(ctx, id) + if err != nil { + r.Base.Log.Error("stripe events: fetch", "event", id, "err", err) + continue + } + if err := wh.Process(ctx, ev); err != nil { + r.Base.Log.Error("stripe events: process", "event", id, "err", err) + } + } + return nil +} + +// seatSync writes each paid Tenant's seat count to Stripe once a day, +// with no proration (Pricing → Stripe catalog). +func (r *Runner) seatSync(ctx context.Context) error { + if r.Stripe == nil { + return nil + } + targets, err := r.Core.PaidTenantSeats(ctx) + if err != nil { + return err + } + day := time.Now().UTC().Format("2006-01-02") + for _, t := range targets { + if t.LastSynced != nil && *t.LastSynced == t.Seats { + continue + } + sub, err := r.Stripe.Subscription(ctx, t.SubscriptionID) + if err != nil { + r.Base.Log.Error("seat sync: read subscription", "tenant", t.TenantID, "err", err) + continue + } + if sub.SeatItemID == "" { + continue // Enterprise contracts without a seat item + } + if err := r.Stripe.SetSeats(ctx, t.TenantID, sub.SeatItemID, t.Seats, day); err != nil { + r.Base.Log.Error("seat sync: write", "tenant", t.TenantID, "err", err) + continue + } + if err := r.Core.RecordSeatSync(ctx, t.TenantID, t.Seats); err != nil { + return err + } + } + return nil +} + +// archiver writes shape-only audit batches as gzipped JSON lines: to S3 +// (Glacier Deep Archive storage class) when AUDIT_ARCHIVE_BUCKET is set, +// otherwise to ./.dev/audit-archive. +func (r *Runner) archiver() core.Archiver { + bucket := os.Getenv("AUDIT_ARCHIVE_BUCKET") + return func(ctx context.Context, batch []core.ArchivedEvent) error { + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + enc := json.NewEncoder(zw) + for _, e := range batch { + if err := enc.Encode(e); err != nil { + return err + } + } + if err := zw.Close(); err != nil { + return err + } + first := batch[0].Timestamp.UTC() + key := fmt.Sprintf("audit/%s/%s-%s.jsonl.gz", first.Format("2006/01/02"), batch[0].ID, batch[len(batch)-1].ID) + if bucket == "" { + path := filepath.Join(".dev", "audit-archive", key) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, buf.Bytes(), 0o644) + } + awsCfg, err := wire.AWS(ctx) + if err != nil { + return err + } + _, err = s3.NewFromConfig(awsCfg).PutObject(ctx, &s3.PutObjectInput{ + Bucket: &bucket, Key: &key, Body: bytes.NewReader(buf.Bytes()), + StorageClass: s3types.StorageClassDeepArchive, ContentType: strp("application/gzip"), + }) + return err + } +} + +func strp(s string) *string { return &s } diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go new file mode 100644 index 0000000..f605fc1 --- /dev/null +++ b/internal/mcp/mcp_test.go @@ -0,0 +1,123 @@ +package mcp_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/Adron/substratalapps.com/internal/core" + "github.com/Adron/substratalapps.com/internal/mcp" + "github.com/Adron/substratalapps.com/internal/testenv" +) + +func TestMain(m *testing.M) { + code := m.Run() + testenv.Drop() + os.Exit(code) +} + +// The MCP destructiveHint and the server's restrict_destructive gate read +// the same classification; they must never disagree. +func TestDestructiveClassificationMatchesSpec(t *testing.T) { + for name, op := range mcp.Tools() { + _, server := core.DestructiveOps[op] + hint := mcp.Annotations(name)["destructiveHint"] == true + if server != hint { + t.Errorf("%s (%s): server classification %v, spec x-substratal-destructive %v", name, op, server, hint) + } + } + if len(mcp.Tools()) < 90 { + t.Fatalf("only %d tools generated", len(mcp.Tools())) + } +} + +type client struct { + t *testing.T + url string + token string + session string + id int +} + +func (c *client) rpc(method string, params any) (map[string]any, int) { + c.id++ + b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": c.id, "method": method, "params": params}) + req, _ := http.NewRequest("POST", c.url, bytes.NewReader(b)) + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json") + if c.session != "" { + req.Header.Set("Mcp-Session-Id", c.session) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + c.t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if s := resp.Header.Get("Mcp-Session-Id"); s != "" { + c.session = s + } + raw, _ := io.ReadAll(resp.Body) + var out map[string]any + _ = json.Unmarshal(raw, &out) + return out, resp.StatusCode +} + +func TestMCPEndToEnd(t *testing.T) { + e := testenv.New(t) + srv := httptest.NewServer(&mcp.Server{API: mcp.InProcess(e.Core.Handler()), BaseURL: "http://api.internal", Key: []byte("k")}) + defer srv.Close() + agentKey := e.Post("/v1/api-keys", e.Admin, map[string]any{"name": "agent", "scope": "platform", "intended_use": "agent", + "permissions": []string{"users.list", "entitlements.manage", "audit.view"}}).Expect(201).Str("secret") + c := &client{t: t, url: srv.URL, token: agentKey} + + if _, status := c.rpc("tools/list", nil); status != 404 { + t.Fatalf("tools/list before initialize = %d", status) + } + init, _ := c.rpc("initialize", map[string]any{"protocolVersion": "2025-06-18", "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "test", "version": "1"}}) + if init["result"].(map[string]any)["protocolVersion"] != "2025-06-18" || c.session == "" { + t.Fatalf("initialize = %v", init) + } + tools, _ := c.rpc("tools/list", nil) + if n := len(tools["result"].(map[string]any)["tools"].([]any)); n < 90 { + t.Fatalf("tools = %d", n) + } + res, _ := c.rpc("resources/read", map[string]any{"uri": "https://adron.github.io/substratalapps.com/llms.txt"}) + if !strings.Contains(res["result"].(map[string]any)["contents"].([]any)[0].(map[string]any)["text"].(string), "Substratal") { + t.Fatal("llms.txt resource") + } + + app := e.App("usr_01JAG0SYSTEM00000000000000", nil) + u := e.Signup() + // A non-destructive tool call works, through the full REST stack. + grant := map[string]any{"name": "substratal_entitlements_grant", "arguments": map[string]any{"id": u.ID, + "body": map[string]any{"application_id": app, "source": "admin_grant"}}} + r1, _ := c.rpc("tools/call", grant) + result := r1["result"].(map[string]any) + if result["isError"] == true { + t.Fatalf("grant failed: %v", result) + } + entID := result["structuredContent"].(map[string]any)["id"].(string) + // A retried tool call replays instead of double-granting. + r2, _ := c.rpc("tools/call", grant) + if r2["result"].(map[string]any)["structuredContent"].(map[string]any)["id"] != entID { + t.Fatalf("retry = %v", r2) + } + // A destructive call is refused server-side for the agent key. + off, _ := c.rpc("tools/call", map[string]any{"name": "substratal_entitlements_update", "arguments": map[string]any{ + "id": entID, "body": map[string]any{"status": "disabled", "disabled_reason": "agent"}}}) + or := off["result"].(map[string]any) + if or["isError"] != true || or["structuredContent"].(map[string]any)["error"].(map[string]any)["code"] != "destructive_operation_restricted" { + t.Fatalf("destructive call = %v", or) + } + // A tampered session is rejected. + c.session = c.session + "x" + if _, status := c.rpc("ping", nil); status != 404 { + t.Fatalf("tampered session = %d", status) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..b3f4f3a --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,420 @@ +// Package mcp is the MCP server at /mcp (docs/mcp-server.md): Streamable +// HTTP, JSON-RPC 2.0, one buffered JSON response per request, no server +// push. It's a protocol gateway, not a new capability: every tool call is +// forwarded, with the caller's own Bearer credential unchanged, to the +// matching /v1 REST call, so Access Control, restrict_destructive, rate +// limits, and the audit trail all apply exactly as they do to curl. +// +// The session is stateless: Mcp-Session-Id is a signed, self-contained +// token carrying the negotiated protocol version, so no table, no cache, +// and any Lambda instance can serve any call. +package mcp + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + _ "embed" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +//go:embed tools.json +var catalogJSON []byte + +type tool struct { + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description"` + InputSchema map[string]any `json:"inputSchema"` + Annotations map[string]any `json:"annotations"` + OperationID string `json:"x_operation_id"` + Method string `json:"x_method"` + Path string `json:"x_path"` + PathParams []string `json:"x_path_params"` + QueryParams []string `json:"x_query_params"` + HasBody bool `json:"x_has_body"` + NeedsIdemKey bool `json:"x_needs_idempotency_key"` +} + +type resource struct { + URI string `json:"uri"` + Name string `json:"name"` + Description string `json:"description"` + MimeType string `json:"mimeType"` + Text string `json:"text"` +} + +var catalog struct { + Tools []tool `json:"tools"` + Resources []resource `json:"resources"` +} + +func init() { + if err := json.Unmarshal(catalogJSON, &catalog); err != nil { + panic("mcp: bad tools.json: " + err.Error()) + } +} + +// Tools returns the generated tool names → operationIds (tests). +func Tools() map[string]string { + out := map[string]string{} + for _, t := range catalog.Tools { + out[t.Name] = t.OperationID + } + return out +} + +// Annotations returns a tool's annotations (tests). +func Annotations(name string) map[string]any { + for _, t := range catalog.Tools { + if t.Name == name { + return t.Annotations + } + } + return nil +} + +// SupportedVersions are the MCP protocol versions this server speaks, +// newest first. +var SupportedVersions = []string{"2025-06-18", "2025-03-26"} + +// Server handles POST /mcp. +type Server struct { + // API is where tool calls go: an HTTP client to the API's own /v1 (API + // Gateway in AWS), or an in-process transport in the devserver. + API *http.Client + BaseURL string // e.g. https://api.substratalapps.com + Key []byte // HMAC key for session tokens + Now func() time.Time +} + +const sessionTTL = 24 * time.Hour + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +func (s *Server) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now() +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + case http.MethodGet: + // No server-initiated stream at Tier 0. + w.Header().Set("Allow", "POST, DELETE") + w.WriteHeader(http.StatusMethodNotAllowed) + return + case http.MethodDelete: + w.WriteHeader(http.StatusNoContent) // stateless: nothing to tear down + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if !strings.HasPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer ") { + w.Header().Set("WWW-Authenticate", `Bearer realm="substratal"`) + writeJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]string{ + "code": "unauthenticated", "message": "The MCP endpoint takes the same Bearer credential as the REST API."}}) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + var req rpcRequest + if err := json.Unmarshal(body, &req); err != nil || req.JSONRPC != "2.0" || req.Method == "" { + writeJSON(w, http.StatusBadRequest, rpcResponse{JSONRPC: "2.0", ID: json.RawMessage("null"), + Error: &rpcError{Code: -32700, Message: "parse error: expected one JSON-RPC 2.0 request"}}) + return + } + if req.Method != "initialize" { + version, ok := s.checkSession(r.Header.Get("Mcp-Session-Id")) + if !ok { + // 404 tells a conforming client to start a new session. + writeJSON(w, http.StatusNotFound, rpcResponse{JSONRPC: "2.0", ID: idOrNull(req.ID), + Error: &rpcError{Code: -32001, Message: "session expired or missing; initialize again"}}) + return + } + w.Header().Set("MCP-Protocol-Version", version) + } + if len(req.ID) == 0 { + // A notification (e.g. notifications/initialized): accepted, no body. + w.WriteHeader(http.StatusAccepted) + return + } + result, rerr := s.dispatch(r, req) + resp := rpcResponse{JSONRPC: "2.0", ID: req.ID} + if rerr != nil { + resp.Error = rerr + } else { + resp.Result = result + } + if req.Method == "initialize" && rerr == nil { + v := result.(map[string]any)["protocolVersion"].(string) + w.Header().Set("Mcp-Session-Id", s.newSession(v)) + } + writeJSON(w, http.StatusOK, resp) +} + +func idOrNull(id json.RawMessage) json.RawMessage { + if len(id) == 0 { + return json.RawMessage("null") + } + return id +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func (s *Server) dispatch(r *http.Request, req rpcRequest) (any, *rpcError) { + switch req.Method { + case "initialize": + var p struct { + ProtocolVersion string `json:"protocolVersion"` + } + _ = json.Unmarshal(req.Params, &p) + version := SupportedVersions[0] + for _, v := range SupportedVersions { + if v == p.ProtocolVersion { + version = v + } + } + return map[string]any{ + "protocolVersion": version, + "capabilities": map[string]any{"tools": map[string]any{"listChanged": false}, "resources": map[string]any{"listChanged": false}}, + "serverInfo": map[string]any{"name": "substratal-apps", "title": "Substratal Apps Platform API", "version": "1.0.0"}, + "instructions": "Tools map 1:1 to the Substratal Apps REST API (operationId → substratal_). " + + "Your Bearer credential is forwarded unchanged; use an intended_use: agent API Key, whose restrict_destructive " + + "default blocks destructive operations server-side. Read the llms.txt resource first.", + }, nil + case "ping": + return map[string]any{}, nil + case "tools/list": + out := make([]map[string]any, 0, len(catalog.Tools)) + for _, t := range catalog.Tools { + out = append(out, map[string]any{"name": t.Name, "title": t.Title, "description": t.Description, + "inputSchema": t.InputSchema, "annotations": t.Annotations}) + } + return map[string]any{"tools": out}, nil + case "tools/call": + var p struct { + Name string `json:"name"` + Arguments map[string]json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal(req.Params, &p); err != nil { + return nil, &rpcError{Code: -32602, Message: "invalid params"} + } + for _, t := range catalog.Tools { + if t.Name == p.Name { + return s.call(r, t, p.Arguments), nil + } + } + return nil, &rpcError{Code: -32602, Message: "unknown tool: " + p.Name} + case "resources/list": + out := make([]map[string]any, 0, len(catalog.Resources)) + for _, res := range catalog.Resources { + out = append(out, map[string]any{"uri": res.URI, "name": res.Name, "description": res.Description, "mimeType": res.MimeType}) + } + return map[string]any{"resources": out}, nil + case "resources/read": + var p struct { + URI string `json:"uri"` + } + _ = json.Unmarshal(req.Params, &p) + for _, res := range catalog.Resources { + if res.URI == p.URI { + return map[string]any{"contents": []map[string]any{{"uri": res.URI, "mimeType": res.MimeType, "text": res.Text}}}, nil + } + } + return nil, &rpcError{Code: -32002, Message: "resource not found"} + } + return nil, &rpcError{Code: -32601, Message: "method not found: " + req.Method} +} + +// call translates one tool call into its REST request, forwarding the +// caller's Authorization header unchanged, and returns the MCP result. A +// REST error is a tool result with isError, never a protocol error. +func (s *Server) call(r *http.Request, t tool, args map[string]json.RawMessage) map[string]any { + path := t.Path + for _, name := range t.PathParams { + var v string + if err := json.Unmarshal(args[name], &v); err != nil || v == "" { + return toolError(fmt.Sprintf("missing required argument %q", name)) + } + path = strings.ReplaceAll(path, "{"+name+"}", url.PathEscape(v)) + } + q := url.Values{} + for _, name := range t.QueryParams { + raw, ok := args[name] + if !ok { + continue + } + var list []any + if json.Unmarshal(raw, &list) == nil { + for _, x := range list { + q.Add(name, fmt.Sprint(x)) + } + continue + } + var x any + _ = json.Unmarshal(raw, &x) + q.Set(name, fmt.Sprint(x)) + } + if len(q) > 0 { + path += "?" + q.Encode() + } + var body io.Reader + if t.HasBody { + b := args["body"] + if len(b) == 0 { + b = json.RawMessage("{}") + } + body = bytes.NewReader(b) + } + ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, t.Method, s.BaseURL+path, body) + if err != nil { + return toolError(err.Error()) + } + req.Header.Set("Authorization", r.Header.Get("Authorization")) + req.Header.Set("Content-Type", "application/json") + if rid := r.Header.Get("X-Request-Id"); rid != "" { + req.Header.Set("X-Request-Id", rid) + } + if raw, ok := args["if_match"]; ok { + var v string + _ = json.Unmarshal(raw, &v) + req.Header.Set("If-Match", v) + } + if t.NeedsIdemKey { + // Derived from the arguments, so a retried tool call can't double-apply. + canon, _ := json.Marshal(args) + sum := sha256.Sum256(append([]byte(t.Name+"\n"), canon...)) + req.Header.Set("Idempotency-Key", "mcp-"+hex.EncodeToString(sum[:16])) + } + resp, err := s.API.Do(req) + if err != nil { + return toolError("the API is unreachable: " + err.Error()) + } + defer func() { _ = resp.Body.Close() }() + out, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + text := string(out) + if text == "" { + text = fmt.Sprintf(`{"status": %d}`, resp.StatusCode) + } + result := map[string]any{"content": []map[string]any{{"type": "text", "text": text}}} + var structured map[string]any + if json.Unmarshal(out, &structured) == nil { + result["structuredContent"] = structured + } + if resp.StatusCode >= 400 { + result["isError"] = true + } + return result +} + +func toolError(msg string) map[string]any { + return map[string]any{"content": []map[string]any{{"type": "text", "text": msg}}, "isError": true} +} + +// ── Stateless session tokens ─────────────────────────────────────────── + +type session struct { + V string `json:"v"` + Exp int64 `json:"e"` +} + +func (s *Server) newSession(version string) string { + b, _ := json.Marshal(session{V: version, Exp: s.now().Add(sessionTTL).Unix()}) + payload := base64.RawURLEncoding.EncodeToString(b) + mac := hmac.New(sha256.New, s.Key) + mac.Write([]byte("mcp-session." + payload)) + return payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16]) +} + +func (s *Server) checkSession(tok string) (string, bool) { + payload, sig, ok := strings.Cut(tok, ".") + if !ok { + return "", false + } + mac := hmac.New(sha256.New, s.Key) + mac.Write([]byte("mcp-session." + payload)) + if !hmac.Equal([]byte(sig), []byte(base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16]))) { + return "", false + } + raw, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + return "", false + } + var ss session + if json.Unmarshal(raw, &ss) != nil || s.now().Unix() > ss.Exp { + return "", false + } + return ss.V, true +} + +// InProcess is an http.Client that calls handler directly, for the +// devserver and tests: tool calls still go through the full API stack. +func InProcess(handler http.Handler) *http.Client { + return &http.Client{Transport: roundTripper{handler}} +} + +type roundTripper struct{ h http.Handler } + +func (rt roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + rec := &recorder{header: http.Header{}, status: 200} + rt.h.ServeHTTP(rec, req) + return &http.Response{StatusCode: rec.status, Header: rec.header, Body: io.NopCloser(&rec.body), Request: req}, nil +} + +type recorder struct { + header http.Header + status int + body bytes.Buffer + wrote bool +} + +func (r *recorder) Header() http.Header { return r.header } +func (r *recorder) WriteHeader(s int) { + if !r.wrote { + r.status, r.wrote = s, true + } +} +func (r *recorder) Write(b []byte) (int, error) { + r.wrote = true + return r.body.Write(b) +} diff --git a/internal/mcp/tools.json b/internal/mcp/tools.json new file mode 100644 index 0000000..f669c6b --- /dev/null +++ b/internal/mcp/tools.json @@ -0,0 +1,5182 @@ +{ + "_generated": "by cmd/mcpgen from docs/openapi.yaml; do not edit", + "tools": [ + { + "name": "substratal_apiKeys_create", + "title": "Create a key (api_keys.manage, or the Application's owner for an app-scoped key)", + "description": "Create a key (api_keys.manage, or the Application's owner for an app-scoped key)\n\nREST: POST /v1/api-keys", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "expires_at": { + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "intended_use": { + "default": "service", + "description": "Sets the default for restrict_destructive (true for agent, false for service).", + "enum": [ + "service", + "agent" + ], + "type": "string" + }, + "mode": { + "description": "Fixed at creation.", + "enum": [ + "live", + "test" + ], + "type": "string" + }, + "name": { + "maxLength": 100, + "type": "string" + }, + "permissions": { + "items": { + "type": "string" + }, + "type": "array" + }, + "restrict_destructive": { + "description": "When true, every operation marked x-substratal-destructive is rejected with 403 destructive_operation_restricted.", + "type": "boolean" + }, + "scope": { + "description": "'platform' or an application_id", + "type": "string" + } + }, + "required": [ + "name", + "scope", + "permissions", + "mode" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create a key (api_keys.manage, or the Application's owner for an app-scoped key)" + }, + "x_operation_id": "apiKeys.create", + "x_method": "POST", + "x_path": "/v1/api-keys", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_apiKeys_delete", + "title": "Revoke permanently", + "description": "Revoke permanently\n\nREST: DELETE /v1/api-keys/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Revoke permanently" + }, + "x_operation_id": "apiKeys.delete", + "x_method": "DELETE", + "x_path": "/v1/api-keys/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_apiKeys_get", + "title": "Fetch one key (never the secret)", + "description": "Fetch one key (never the secret)\n\nREST: GET /v1/api-keys/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Fetch one key (never the secret)" + }, + "x_operation_id": "apiKeys.get", + "x_method": "GET", + "x_path": "/v1/api-keys/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_apiKeys_list", + "title": "List keys the caller may manage", + "description": "List keys the caller may manage\n\nREST: GET /v1/api-keys", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": "string" + }, + "include_inactive": { + "default": false, + "description": "Include revoked and expired keys", + "type": "boolean" + }, + "intended_use": { + "enum": [ + "service", + "agent" + ], + "type": "string" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "mode": { + "enum": [ + "live", + "test" + ], + "type": "string" + }, + "scope": { + "type": "string" + } + }, + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List keys the caller may manage" + }, + "x_operation_id": "apiKeys.list", + "x_method": "GET", + "x_path": "/v1/api-keys", + "x_path_params": null, + "x_query_params": [ + "limit", + "cursor", + "scope", + "mode", + "intended_use", + "include_inactive" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_apiKeys_rotate", + "title": "Issue a new secret, invalidating the old one immediately (no overlap)", + "description": "Issue a new secret, invalidating the old one immediately (no overlap)\n\nREST: POST /v1/api-keys/{id}/rotate", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Issue a new secret, invalidating the old one immediately (no overlap)" + }, + "x_operation_id": "apiKeys.rotate", + "x_method": "POST", + "x_path": "/v1/api-keys/{id}/rotate", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_apiKeys_update", + "title": "Change name, permissions, or restrict_destructive", + "description": "Change name, permissions, or restrict_destructive\n\nREST: PATCH /v1/api-keys/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "name": { + "maxLength": 100, + "type": "string" + }, + "permissions": { + "items": { + "type": "string" + }, + "type": "array" + }, + "restrict_destructive": { + "type": "boolean" + } + }, + "type": "object" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Change name, permissions, or restrict_destructive" + }, + "x_operation_id": "apiKeys.update", + "x_method": "PATCH", + "x_path": "/v1/api-keys/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_appProfiles_get", + "title": "Fetch per-app AppProfile (self, the app's own key, or platform users.list; defaults if none written yet)", + "description": "Fetch per-app AppProfile (self, the app's own key, or platform users.list; defaults if none written yet)\n\nREST: GET /v1/users/{id}/apps/{appId}/profile", + "inputSchema": { + "additionalProperties": false, + "properties": { + "appId": { + "type": "string" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id", + "appId" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Fetch per-app AppProfile (self, the app's own key, or platform users.list; defaults if none written yet)" + }, + "x_operation_id": "appProfiles.get", + "x_method": "GET", + "x_path": "/v1/users/{id}/apps/{appId}/profile", + "x_path_params": [ + "id", + "appId" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_appProfiles_update", + "title": "Update per-app AppProfile (custom is merged one level; null removes a key)", + "description": "Update per-app AppProfile (custom is merged one level; null removes a key)\n\nREST: PATCH /v1/users/{id}/apps/{appId}/profile", + "inputSchema": { + "additionalProperties": false, + "properties": { + "appId": { + "type": "string" + }, + "body": { + "properties": { + "custom": { + "type": "object" + }, + "display_handle": { + "maxLength": 64, + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + }, + "if_match": { + "description": "ETag from a prior read; a mismatch returns 409 version_conflict.", + "type": "string" + } + }, + "required": [ + "id", + "appId" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Update per-app AppProfile (custom is merged one level; null removes a key)" + }, + "x_operation_id": "appProfiles.update", + "x_method": "PATCH", + "x_path": "/v1/users/{id}/apps/{appId}/profile", + "x_path_params": [ + "id", + "appId" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": true + }, + { + "name": "substratal_appSettings_get", + "title": "Fetch fully resolved per-app settings (self, the app's own key, or platform users.list)", + "description": "Fetch fully resolved per-app settings (self, the app's own key, or platform users.list)\n\nREST: GET /v1/users/{id}/apps/{appId}/settings", + "inputSchema": { + "additionalProperties": false, + "properties": { + "appId": { + "type": "string" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id", + "appId" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Fetch fully resolved per-app settings (self, the app's own key, or platform users.list)" + }, + "x_operation_id": "appSettings.get", + "x_method": "GET", + "x_path": "/v1/users/{id}/apps/{appId}/settings", + "x_path_params": [ + "id", + "appId" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_appSettings_update", + "title": "Write per-app overrides (validated against settings_schema; null clears)", + "description": "Write per-app overrides (validated against settings_schema; null clears)\n\nREST: PATCH /v1/users/{id}/apps/{appId}/settings", + "inputSchema": { + "additionalProperties": false, + "properties": { + "appId": { + "type": "string" + }, + "body": { + "properties": { + "overrides": { + "type": "object" + } + }, + "required": [ + "overrides" + ], + "type": "object" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + }, + "if_match": { + "description": "ETag from a prior read; a mismatch returns 409 version_conflict.", + "type": "string" + } + }, + "required": [ + "id", + "appId" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Write per-app overrides (validated against settings_schema; null clears)" + }, + "x_operation_id": "appSettings.update", + "x_method": "PATCH", + "x_path": "/v1/users/{id}/apps/{appId}/settings", + "x_path_params": [ + "id", + "appId" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": true + }, + { + "name": "substratal_applications_create", + "title": "Register an Application (applications.manage)", + "description": "Register an Application (applications.manage)\n\nREST: POST /v1/applications", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "allOf": [ + { + "properties": { + "available_app_roles": { + "items": { + "pattern": "^[a-z][a-z0-9_]{1,40}$", + "type": "string" + }, + "maxItems": 20, + "type": "array" + }, + "default_app_role": { + "type": [ + "string", + "null" + ] + }, + "description": { + "maxLength": 2000, + "type": [ + "string", + "null" + ] + }, + "email_from_name": { + "maxLength": 50, + "type": [ + "string", + "null" + ] + }, + "icon_url": { + "format": "uri", + "type": [ + "string", + "null" + ] + }, + "launch_url": { + "format": "uri", + "type": "string" + }, + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "owner_organization_id": { + "type": [ + "string", + "null" + ] + }, + "owner_user_id": { + "type": [ + "string", + "null" + ] + }, + "permissions": { + "items": { + "properties": { + "description": { + "maxLength": 255, + "type": "string" + }, + "key": { + "pattern": "^app\\.[a-z0-9_-]+\\.[a-z0-9_.]{1,64}$", + "type": "string" + } + }, + "required": [ + "key" + ], + "type": "object" + }, + "maxItems": 100, + "type": "array" + }, + "redirect_uris": { + "items": { + "type": "string" + }, + "maxItems": 10, + "type": "array" + }, + "review_notes": { + "maxLength": 2000, + "type": [ + "string", + "null" + ] + }, + "review_status": { + "enum": [ + "approved", + "pending_review", + "rejected", + "suspended" + ], + "type": "string" + }, + "settings_schema": { + "description": "JSON Schema 2020-12 subset — see Domain Model → Settings → settings_schema rules", + "type": "object" + }, + "slug": { + "description": "Create only; immutable. 'platform' is reserved (rejected with validation_failed, code unknown_value). The Application's id is app_\u003cslug\u003e.", + "pattern": "^[a-z][a-z0-9-]{1,63}$", + "type": "string" + }, + "support_url": { + "format": "uri", + "type": [ + "string", + "null" + ] + }, + "visibility": { + "enum": [ + "public", + "invite_only", + "internal" + ], + "type": "string" + } + }, + "type": "object" + }, + { + "required": [ + "slug", + "name", + "launch_url" + ], + "type": "object" + } + ] + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Register an Application (applications.manage)" + }, + "x_operation_id": "applications.create", + "x_method": "POST", + "x_path": "/v1/applications", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_applications_get", + "title": "Fetch one Application", + "description": "Fetch one Application\n\nREST: GET /v1/applications/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Fetch one Application" + }, + "x_operation_id": "applications.get", + "x_method": "GET", + "x_path": "/v1/applications/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_applications_list", + "title": "List the catalog (filtered by visibility rules)", + "description": "List the catalog (filtered by visibility rules)\n\nREST: GET /v1/applications", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": "string" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "owned": { + "type": "boolean" + }, + "owner_organization_id": { + "type": "string" + }, + "owner_user_id": { + "type": "string" + }, + "review_status": { + "enum": [ + "approved", + "pending_review", + "rejected", + "suspended" + ], + "type": "string" + }, + "tenant_id": { + "type": "string" + }, + "visibility": { + "enum": [ + "public", + "invite_only", + "internal" + ], + "type": "string" + } + }, + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List the catalog (filtered by visibility rules)" + }, + "x_operation_id": "applications.list", + "x_method": "GET", + "x_path": "/v1/applications", + "x_path_params": null, + "x_query_params": [ + "limit", + "cursor", + "visibility", + "review_status", + "owned", + "tenant_id", + "owner_user_id", + "owner_organization_id" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_applications_update", + "title": "Update a catalog entry (owner — configuration; applications.manage — anything)", + "description": "Update a catalog entry (owner — configuration; applications.manage — anything)\n\nREST: PATCH /v1/applications/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "available_app_roles": { + "items": { + "pattern": "^[a-z][a-z0-9_]{1,40}$", + "type": "string" + }, + "maxItems": 20, + "type": "array" + }, + "default_app_role": { + "type": [ + "string", + "null" + ] + }, + "description": { + "maxLength": 2000, + "type": [ + "string", + "null" + ] + }, + "email_from_name": { + "maxLength": 50, + "type": [ + "string", + "null" + ] + }, + "icon_url": { + "format": "uri", + "type": [ + "string", + "null" + ] + }, + "launch_url": { + "format": "uri", + "type": "string" + }, + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "owner_organization_id": { + "type": [ + "string", + "null" + ] + }, + "owner_user_id": { + "type": [ + "string", + "null" + ] + }, + "permissions": { + "items": { + "properties": { + "description": { + "maxLength": 255, + "type": "string" + }, + "key": { + "pattern": "^app\\.[a-z0-9_-]+\\.[a-z0-9_.]{1,64}$", + "type": "string" + } + }, + "required": [ + "key" + ], + "type": "object" + }, + "maxItems": 100, + "type": "array" + }, + "redirect_uris": { + "items": { + "type": "string" + }, + "maxItems": 10, + "type": "array" + }, + "review_notes": { + "maxLength": 2000, + "type": [ + "string", + "null" + ] + }, + "review_status": { + "enum": [ + "approved", + "pending_review", + "rejected", + "suspended" + ], + "type": "string" + }, + "settings_schema": { + "description": "JSON Schema 2020-12 subset — see Domain Model → Settings → settings_schema rules", + "type": "object" + }, + "slug": { + "description": "Create only; immutable. 'platform' is reserved (rejected with validation_failed, code unknown_value). The Application's id is app_\u003cslug\u003e.", + "pattern": "^[a-z][a-z0-9-]{1,63}$", + "type": "string" + }, + "support_url": { + "format": "uri", + "type": [ + "string", + "null" + ] + }, + "visibility": { + "enum": [ + "public", + "invite_only", + "internal" + ], + "type": "string" + } + }, + "type": "object" + }, + "id": { + "type": "string" + }, + "if_match": { + "description": "ETag from a prior read; a mismatch returns 409 version_conflict.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Update a catalog entry (owner — configuration; applications.manage — anything)" + }, + "x_operation_id": "applications.update", + "x_method": "PATCH", + "x_path": "/v1/applications/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": true + }, + { + "name": "substratal_audit_get", + "title": "Fetch one Audit Event", + "description": "Fetch one Audit Event\n\nREST: GET /v1/audit-events/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Fetch one Audit Event" + }, + "x_operation_id": "audit.get", + "x_method": "GET", + "x_path": "/v1/audit-events/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_audit_list", + "title": "Query the audit log (hot window only)", + "description": "Query the audit log (hot window only)\n\nREST: GET /v1/audit-events", + "inputSchema": { + "additionalProperties": false, + "properties": { + "action": { + "items": { + "enum": [ + "user.created", + "user.updated", + "user.email_changed", + "user.suspended", + "user.reactivated", + "user.deleted", + "user.erasure_requested", + "user.erasure_cancelled", + "user.erased", + "user.password_changed", + "user.password_reset", + "user.mfa_enabled", + "user.mfa_disabled", + "user.mfa_reset", + "entitlement.granted", + "entitlement.disabled", + "entitlement.revoked", + "entitlement.expired", + "entitlement.updated", + "entitlement.member_scope_changed", + "entitlement.deleted", + "role.created", + "role.updated", + "role.deleted", + "role.assigned", + "role.removed", + "profile.updated", + "settings.updated", + "application.created", + "application.updated", + "application.review_status_changed", + "organization.created", + "organization.updated", + "organization.member_invited", + "organization.member_added", + "organization.member_removed", + "organization.member_role_changed", + "tenant.plan_changed", + "tenant.subscription_status_changed", + "tenant.tier_change_requested", + "tenant.tier_change_request_updated", + "tenant.tier_changed", + "api_key.created", + "api_key.updated", + "api_key.rotated", + "api_key.revoked", + "api_key.restrict_destructive_disabled", + "webhook.created", + "webhook.updated", + "webhook.deleted", + "webhook.secret_rotated" + ], + "type": "string" + }, + "type": "array" + }, + "actor_id": { + "type": "string" + }, + "actor_type": { + "enum": [ + "user", + "api_key", + "system" + ], + "type": "string" + }, + "application_id": { + "type": "string" + }, + "cursor": { + "type": "string" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "organization_id": { + "type": "string" + }, + "since": { + "format": "date-time", + "type": "string" + }, + "target_id": { + "type": "string" + }, + "target_type": { + "enum": [ + "user", + "entitlement", + "role", + "role_assignment", + "application", + "organization", + "tenant", + "tier_change_request", + "api_key", + "webhook" + ], + "type": "string" + }, + "target_user_id": { + "type": "string" + }, + "tenant_id": { + "type": "string" + }, + "until": { + "format": "date-time", + "type": "string" + } + }, + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Query the audit log (hot window only)" + }, + "x_operation_id": "audit.list", + "x_method": "GET", + "x_path": "/v1/audit-events", + "x_path_params": null, + "x_query_params": [ + "limit", + "cursor", + "target_user_id", + "target_type", + "target_id", + "actor_id", + "actor_type", + "action", + "application_id", + "organization_id", + "tenant_id", + "since", + "until" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_acceptInvitation", + "title": "Set a password on an invited account, activate it, and sign in", + "description": "Set a password on an invited account, activate it, and sign in\n\nREST: POST /v1/auth/invitations/accept", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "display_name": { + "maxLength": 100, + "type": "string" + }, + "password": { + "format": "password", + "maxLength": 128, + "minLength": 12, + "type": "string" + }, + "token": { + "type": "string" + } + }, + "required": [ + "token", + "password" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Set a password on an invited account, activate it, and sign in" + }, + "x_operation_id": "auth.acceptInvitation", + "x_method": "POST", + "x_path": "/v1/auth/invitations/accept", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_changePassword", + "title": "Change password given the current one (self only)", + "description": "Change password given the current one (self only)\n\nREST: POST /v1/users/{id}/password", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "current_password": { + "format": "password", + "type": "string" + }, + "new_password": { + "format": "password", + "maxLength": 128, + "minLength": 12, + "type": "string" + } + }, + "required": [ + "current_password", + "new_password" + ], + "type": "object" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Change password given the current one (self only)" + }, + "x_operation_id": "auth.changePassword", + "x_method": "POST", + "x_path": "/v1/users/{id}/password", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_confirmMfaTotp", + "title": "Confirm TOTP enrollment with a first code; returns recovery codes once", + "description": "Confirm TOTP enrollment with a first code; returns recovery codes once\n\nREST: POST /v1/users/{id}/mfa/totp/confirm", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "code": { + "pattern": "^[0-9]{6}$", + "type": "string" + } + }, + "required": [ + "code" + ], + "type": "object" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Confirm TOTP enrollment with a first code; returns recovery codes once" + }, + "x_operation_id": "auth.confirmMfaTotp", + "x_method": "POST", + "x_path": "/v1/users/{id}/mfa/totp/confirm", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_createAppToken", + "title": "Mint a per-app JWT from a platform session (embedded login)", + "description": "Mint a per-app JWT from a platform session (embedded login)\n\nRequires a User token. Fails with 403 entitlement_required unless the user's resolved access to the Application is active, 409 application_not_available unless it's approved, and 403 tenant_suspended if its Tenant is suspended.\n\nREST: POST /v1/auth/app-tokens", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "application_id": { + "type": "string" + }, + "organization_id": { + "description": "Attribute org_id to this Organization's grant.", + "type": "string" + } + }, + "required": [ + "application_id" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Mint a per-app JWT from a platform session (embedded login)" + }, + "x_operation_id": "auth.createAppToken", + "x_method": "POST", + "x_path": "/v1/auth/app-tokens", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_createAuthorizationCode", + "title": "Mint a one-time authorization code for a hosted launch (PKCE)", + "description": "Mint a one-time authorization code for a hosted launch (PKCE)\n\nCalled by Substratal's hosted sign-in page on the signed-in user's behalf.\n\nREST: POST /v1/auth/oauth/authorization-codes", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "application_id": { + "type": "string" + }, + "code_challenge": { + "maxLength": 128, + "minLength": 43, + "type": "string" + }, + "code_challenge_method": { + "enum": [ + "S256" + ], + "type": "string" + }, + "redirect_uri": { + "type": "string" + }, + "state": { + "maxLength": 512, + "type": "string" + } + }, + "required": [ + "application_id", + "redirect_uri", + "code_challenge", + "code_challenge_method" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Mint a one-time authorization code for a hosted launch (PKCE)" + }, + "x_operation_id": "auth.createAuthorizationCode", + "x_method": "POST", + "x_path": "/v1/auth/oauth/authorization-codes", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_disableMfaTotp", + "title": "Turn TOTP off given a current code or recovery code (self only)", + "description": "Turn TOTP off given a current code or recovery code (self only)\n\nREST: POST /v1/users/{id}/mfa/totp/disable", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Exactly one of code or recovery_code.", + "properties": { + "code": { + "description": "A current TOTP code", + "pattern": "^[0-9]{6}$", + "type": "string" + }, + "recovery_code": { + "type": "string" + } + }, + "type": "object" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Turn TOTP off given a current code or recovery code (self only)" + }, + "x_operation_id": "auth.disableMfaTotp", + "x_method": "POST", + "x_path": "/v1/users/{id}/mfa/totp/disable", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_enrollMfaTotp", + "title": "Begin TOTP enrollment (self only)", + "description": "Begin TOTP enrollment (self only)\n\nREST: POST /v1/users/{id}/mfa/totp", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Begin TOTP enrollment (self only)" + }, + "x_operation_id": "auth.enrollMfaTotp", + "x_method": "POST", + "x_path": "/v1/users/{id}/mfa/totp", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_exchangeOAuthToken", + "title": "Exchange an authorization code or app refresh token for an app token", + "description": "Exchange an authorization code or app refresh token for an app token\n\nPublic-client OAuth 2.0 token endpoint. Errors use the OAuth error shape ({error, error_description}), not the usual envelope.\n\nREST: POST /v1/auth/oauth/token", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "client_id": { + "description": "The application_id", + "type": "string" + }, + "code": { + "type": "string" + }, + "code_verifier": { + "type": "string" + }, + "grant_type": { + "enum": [ + "authorization_code", + "refresh_token" + ], + "type": "string" + }, + "redirect_uri": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + }, + "required": [ + "grant_type", + "client_id" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Exchange an authorization code or app refresh token for an app token" + }, + "x_operation_id": "auth.exchangeOAuthToken", + "x_method": "POST", + "x_path": "/v1/auth/oauth/token", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_forgotPassword", + "title": "Send a password-reset email (always 202)", + "description": "Send a password-reset email (always 202)\n\nREST: POST /v1/auth/password/forgot", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "test_mode": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "email" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Send a password-reset email (always 202)" + }, + "x_operation_id": "auth.forgotPassword", + "x_method": "POST", + "x_path": "/v1/auth/password/forgot", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_listSessions", + "title": "List active sessions (self or users.manage)", + "description": "List active sessions (self or users.manage)\n\nREST: GET /v1/users/{id}/sessions", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": "string" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List active sessions (self or users.manage)" + }, + "x_operation_id": "auth.listSessions", + "x_method": "GET", + "x_path": "/v1/users/{id}/sessions", + "x_path_params": [ + "id" + ], + "x_query_params": [ + "limit", + "cursor" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_login", + "title": "Exchange email and password for a session (or an MFA challenge)", + "description": "Exchange email and password for a session (or an MFA challenge)\n\nREST: POST /v1/auth/login", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "password": { + "format": "password", + "type": "string" + }, + "test_mode": { + "default": false, + "description": "Sign in the test-mode account with this email.", + "type": "boolean" + } + }, + "required": [ + "email", + "password" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Exchange email and password for a session (or an MFA challenge)" + }, + "x_operation_id": "auth.login", + "x_method": "POST", + "x_path": "/v1/auth/login", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_logout", + "title": "Revoke the current session, or every session", + "description": "Revoke the current session, or every session\n\nREST: POST /v1/auth/logout", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "all_sessions": { + "default": false, + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Revoke the current session, or every session" + }, + "x_operation_id": "auth.logout", + "x_method": "POST", + "x_path": "/v1/auth/logout", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_refreshToken", + "title": "Rotate a platform refresh token for a new access token", + "description": "Rotate a platform refresh token for a new access token\n\nREST: POST /v1/auth/token/refresh", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "refresh_token": { + "type": "string" + } + }, + "required": [ + "refresh_token" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Rotate a platform refresh token for a new access token" + }, + "x_operation_id": "auth.refreshToken", + "x_method": "POST", + "x_path": "/v1/auth/token/refresh", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_resendEmailVerification", + "title": "Re-send a verification email (always 202)", + "description": "Re-send a verification email (always 202)\n\nREST: POST /v1/auth/email/verify/resend", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "test_mode": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "email" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Re-send a verification email (always 202)" + }, + "x_operation_id": "auth.resendEmailVerification", + "x_method": "POST", + "x_path": "/v1/auth/email/verify/resend", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_resetMfaTotp", + "title": "Support reset of a user's TOTP (requires users.manage)", + "description": "Support reset of a user's TOTP (requires users.manage)\n\nREST: DELETE /v1/users/{id}/mfa/totp", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Support reset of a user's TOTP (requires users.manage)" + }, + "x_operation_id": "auth.resetMfaTotp", + "x_method": "DELETE", + "x_path": "/v1/users/{id}/mfa/totp", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_resetPassword", + "title": "Set a new password from a reset token (revokes every session)", + "description": "Set a new password from a reset token (revokes every session)\n\nREST: POST /v1/auth/password/reset", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "new_password": { + "format": "password", + "maxLength": 128, + "minLength": 12, + "type": "string" + }, + "token": { + "type": "string" + } + }, + "required": [ + "token", + "new_password" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Set a new password from a reset token (revokes every session)" + }, + "x_operation_id": "auth.resetPassword", + "x_method": "POST", + "x_path": "/v1/auth/password/reset", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_revokeSession", + "title": "Revoke one session", + "description": "Revoke one session\n\nREST: DELETE /v1/users/{id}/sessions/{sessionId}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": [ + "id", + "sessionId" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Revoke one session" + }, + "x_operation_id": "auth.revokeSession", + "x_method": "DELETE", + "x_path": "/v1/users/{id}/sessions/{sessionId}", + "x_path_params": [ + "id", + "sessionId" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_signup", + "title": "Create an account with email and password", + "description": "Create an account with email and password\n\nAn email held by an invited User is rejected with 409 email_taken (details.reason invitation_pending) and the invitation is re-sent; invitations complete only through invitations/accept.\n\nREST: POST /v1/auth/signup", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "application_id": { + "description": "Informational: records which app the signup came from. Grants nothing.", + "type": "string" + }, + "display_name": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + }, + "password": { + "format": "password", + "maxLength": 128, + "minLength": 12, + "type": "string" + }, + "test_mode": { + "default": false, + "description": "Create a test-mode User.", + "type": "boolean" + } + }, + "required": [ + "email", + "password" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create an account with email and password" + }, + "x_operation_id": "auth.signup", + "x_method": "POST", + "x_path": "/v1/auth/signup", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_ssoCallback", + "title": "Complete an SSO login (reserved — 501 until the broker integration ships)", + "description": "Complete an SSO login (reserved — 501 until the broker integration ships)\n\nREST: POST /v1/auth/sso/{provider}/callback", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "type": "object" + }, + "provider": { + "type": "string" + } + }, + "required": [ + "provider" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Complete an SSO login (reserved — 501 until the broker integration ships)" + }, + "x_operation_id": "auth.ssoCallback", + "x_method": "POST", + "x_path": "/v1/auth/sso/{provider}/callback", + "x_path_params": [ + "provider" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_verifyEmail", + "title": "Confirm an email address from an emailed token", + "description": "Confirm an email address from an emailed token\n\nREST: POST /v1/auth/email/verify", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "token": { + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Confirm an email address from an emailed token" + }, + "x_operation_id": "auth.verifyEmail", + "x_method": "POST", + "x_path": "/v1/auth/email/verify", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_auth_verifyMfa", + "title": "Complete a login that returned an MFA challenge", + "description": "Complete a login that returned an MFA challenge\n\nREST: POST /v1/auth/mfa/verify", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "code": { + "pattern": "^[0-9]{6}$", + "type": "string" + }, + "mfa_token": { + "type": "string" + }, + "recovery_code": { + "type": "string" + } + }, + "required": [ + "mfa_token" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Complete a login that returned an MFA challenge" + }, + "x_operation_id": "auth.verifyMfa", + "x_method": "POST", + "x_path": "/v1/auth/mfa/verify", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_billing_createCheckoutSession", + "title": "Start a Stripe Checkout to subscribe to Team (owner)", + "description": "Start a Stripe Checkout to subscribe to Team (owner)\n\nREST: POST /v1/tenants/{id}/billing/checkout-sessions", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "cancel_url": { + "format": "uri", + "type": "string" + }, + "plan": { + "enum": [ + "team" + ], + "type": "string" + }, + "success_url": { + "format": "uri", + "type": "string" + } + }, + "required": [ + "plan", + "success_url", + "cancel_url" + ], + "type": "object" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Start a Stripe Checkout to subscribe to Team (owner)" + }, + "x_operation_id": "billing.createCheckoutSession", + "x_method": "POST", + "x_path": "/v1/tenants/{id}/billing/checkout-sessions", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": true, + "x_accepts_if_match": false + }, + { + "name": "substratal_billing_createPortalSession", + "title": "Open the Stripe Customer Portal (owner or billing.manage)", + "description": "Open the Stripe Customer Portal (owner or billing.manage)\n\nREST: POST /v1/tenants/{id}/billing/portal-sessions", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "return_url": { + "format": "uri", + "type": "string" + } + }, + "required": [ + "return_url" + ], + "type": "object" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Open the Stripe Customer Portal (owner or billing.manage)" + }, + "x_operation_id": "billing.createPortalSession", + "x_method": "POST", + "x_path": "/v1/tenants/{id}/billing/portal-sessions", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_billing_getSubscription", + "title": "Current plan, subscription status, period, and seats", + "description": "Current plan, subscription status, period, and seats\n\nREST: GET /v1/tenants/{id}/subscription", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Current plan, subscription status, period, and seats" + }, + "x_operation_id": "billing.getSubscription", + "x_method": "GET", + "x_path": "/v1/tenants/{id}/subscription", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_billing_getUsage", + "title": "Usage against every plan limit", + "description": "Usage against every plan limit\n\nREST: GET /v1/tenants/{id}/usage", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Usage against every plan limit" + }, + "x_operation_id": "billing.getUsage", + "x_method": "GET", + "x_path": "/v1/tenants/{id}/usage", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_entitlements_delete", + "title": "Hard-remove a grant made in error (no order_id, under 24 hours old)", + "description": "Hard-remove a grant made in error (no order_id, under 24 hours old)\n\nREST: DELETE /v1/entitlements/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Hard-remove a grant made in error (no order_id, under 24 hours old)" + }, + "x_operation_id": "entitlements.delete", + "x_method": "DELETE", + "x_path": "/v1/entitlements/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_entitlements_get", + "title": "Fetch one entitlement", + "description": "Fetch one entitlement\n\nREST: GET /v1/entitlements/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Fetch one entitlement" + }, + "x_operation_id": "entitlements.get", + "x_method": "GET", + "x_path": "/v1/entitlements/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_entitlements_grant", + "title": "Grant a personal Entitlement", + "description": "Grant a personal Entitlement\n\nRequires entitlements.manage (platform, or app-confined to the key's own Application), or the Application's owner.\n\nREST: POST /v1/users/{id}/entitlements", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "application_id": { + "type": "string" + }, + "ends_at": { + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "order_id": { + "maxLength": 255, + "type": [ + "string", + "null" + ] + }, + "source": { + "enum": [ + "purchase", + "trial", + "admin_grant" + ], + "type": "string" + }, + "starts_at": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "application_id", + "source" + ], + "type": "object" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Grant a personal Entitlement" + }, + "x_operation_id": "entitlements.grant", + "x_method": "POST", + "x_path": "/v1/users/{id}/entitlements", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": true, + "x_accepts_if_match": false + }, + { + "name": "substratal_entitlements_list", + "title": "List entitlements across users (entitlements.manage or the Application's owner; app keys and owners confined)", + "description": "List entitlements across users (entitlements.manage or the Application's owner; app keys and owners confined)\n\nREST: GET /v1/entitlements", + "inputSchema": { + "additionalProperties": false, + "properties": { + "application_id": { + "type": "string" + }, + "cursor": { + "type": "string" + }, + "ends_before": { + "format": "date-time", + "type": "string" + }, + "include_inactive": { + "default": false, + "type": "boolean" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "order_id": { + "type": "string" + }, + "organization_id": { + "type": "string" + }, + "source": { + "enum": [ + "purchase", + "trial", + "admin_grant", + "org_seat" + ], + "type": "string" + }, + "status": { + "enum": [ + "active", + "disabled", + "expired", + "revoked" + ], + "type": "string" + }, + "user_id": { + "type": "string" + } + }, + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List entitlements across users (entitlements.manage or the Application's owner; app keys and owners confined)" + }, + "x_operation_id": "entitlements.list", + "x_method": "GET", + "x_path": "/v1/entitlements", + "x_path_params": null, + "x_query_params": [ + "limit", + "cursor", + "application_id", + "status", + "user_id", + "organization_id", + "source", + "order_id", + "ends_before", + "include_inactive" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_entitlements_listForUser", + "title": "A user's entitlements, personal and org-sourced", + "description": "A user's entitlements, personal and org-sourced\n\nREST: GET /v1/users/{id}/entitlements", + "inputSchema": { + "additionalProperties": false, + "properties": { + "application_id": { + "type": "string" + }, + "cursor": { + "type": "string" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + }, + "include_inactive": { + "default": false, + "type": "boolean" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "source": { + "enum": [ + "purchase", + "trial", + "admin_grant", + "org_seat" + ], + "type": "string" + }, + "status": { + "enum": [ + "active", + "disabled", + "expired", + "revoked" + ], + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "A user's entitlements, personal and org-sourced" + }, + "x_operation_id": "entitlements.listForUser", + "x_method": "GET", + "x_path": "/v1/users/{id}/entitlements", + "x_path_params": [ + "id" + ], + "x_query_params": [ + "limit", + "cursor", + "application_id", + "status", + "source", + "include_inactive" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_entitlements_update", + "title": "The toggle: change status, term, source (trial→purchase), order_id, or an org grant's member scope", + "description": "The toggle: change status, term, source (trial→purchase), order_id, or an org grant's member scope\n\nREST: PATCH /v1/entitlements/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "disabled_reason": { + "maxLength": 255, + "type": [ + "string", + "null" + ] + }, + "ends_at": { + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "member_overrides": { + "items": { + "type": "string" + }, + "maxItems": 1000, + "type": "array" + }, + "member_scope": { + "enum": [ + "all_members", + "allowlist", + "denylist" + ], + "type": "string" + }, + "order_id": { + "maxLength": 255, + "type": "string" + }, + "source": { + "enum": [ + "purchase" + ], + "type": "string" + }, + "status": { + "enum": [ + "active", + "disabled", + "expired", + "revoked" + ], + "type": "string" + } + }, + "type": "object" + }, + "id": { + "type": "string" + }, + "if_match": { + "description": "ETag from a prior read; a mismatch returns 409 version_conflict.", + "type": "string" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "The toggle: change status, term, source (trial→purchase), order_id, or an org grant's member scope" + }, + "x_operation_id": "entitlements.update", + "x_method": "PATCH", + "x_path": "/v1/entitlements/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": true + }, + { + "name": "substratal_health_check", + "title": "Health check", + "description": "Health check\n\nREST: GET /v1/health", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Health check" + }, + "x_operation_id": "health.check", + "x_method": "GET", + "x_path": "/v1/health", + "x_path_params": null, + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_organizations_acceptMembership", + "title": "Accept a pending membership (the invited User's own token)", + "description": "Accept a pending membership (the invited User's own token)\n\nREST: POST /v1/organizations/{id}/members/me/accept", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Accept a pending membership (the invited User's own token)" + }, + "x_operation_id": "organizations.acceptMembership", + "x_method": "POST", + "x_path": "/v1/organizations/{id}/members/me/accept", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_organizations_addMember", + "title": "Invite a member by email (org admin), or add one directly by user_id (organizations.manage)", + "description": "Invite a member by email (org admin), or add one directly by user_id (organizations.manage)\n\nBy email, creates a pending membership (and an invited User if none exists) and emails an invitation; the response is identical whether or not the email already had an account. By user_id (organizations.manage only), the membership is active immediately. Never fails on a plan limit.\n\nREST: POST /v1/organizations/{id}/members", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "oneOf": [ + { + "required": [ + "user_id" + ] + }, + { + "required": [ + "email" + ] + } + ], + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "role": { + "enum": [ + "org_admin", + "member" + ], + "type": "string" + }, + "user_id": { + "type": "string" + } + }, + "type": "object" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Invite a member by email (org admin), or add one directly by user_id (organizations.manage)" + }, + "x_operation_id": "organizations.addMember", + "x_method": "POST", + "x_path": "/v1/organizations/{id}/members", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_organizations_create", + "title": "Create an Organization (any active User; caller becomes org_admin)", + "description": "Create an Organization (any active User; caller becomes org_admin)\n\nREST: POST /v1/organizations", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create an Organization (any active User; caller becomes org_admin)" + }, + "x_operation_id": "organizations.create", + "x_method": "POST", + "x_path": "/v1/organizations", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_organizations_get", + "title": "Fetch one Organization", + "description": "Fetch one Organization\n\nREST: GET /v1/organizations/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Fetch one Organization" + }, + "x_operation_id": "organizations.get", + "x_method": "GET", + "x_path": "/v1/organizations/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_organizations_grantEntitlement", + "title": "Grant an app to the whole Organization", + "description": "Grant an app to the whole Organization\n\nRequires entitlements.manage (platform, or the Application's own confined key) or the Application's owner. Never the org's own admin.\n\nREST: POST /v1/organizations/{id}/entitlements", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "application_id": { + "type": "string" + }, + "ends_at": { + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "member_overrides": { + "items": { + "type": "string" + }, + "maxItems": 1000, + "type": "array" + }, + "member_scope": { + "enum": [ + "all_members", + "allowlist", + "denylist" + ], + "type": "string" + }, + "order_id": { + "maxLength": 255, + "type": [ + "string", + "null" + ] + }, + "starts_at": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "application_id" + ], + "type": "object" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Grant an app to the whole Organization" + }, + "x_operation_id": "organizations.grantEntitlement", + "x_method": "POST", + "x_path": "/v1/organizations/{id}/entitlements", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": true, + "x_accepts_if_match": false + }, + { + "name": "substratal_organizations_list", + "title": "List your Organizations (all, with organizations.manage)", + "description": "List your Organizations (all, with organizations.manage)\n\nREST: GET /v1/organizations", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": "string" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "q": { + "type": "string" + }, + "status": { + "enum": [ + "active", + "suspended" + ], + "type": "string" + } + }, + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List your Organizations (all, with organizations.manage)" + }, + "x_operation_id": "organizations.list", + "x_method": "GET", + "x_path": "/v1/organizations", + "x_path_params": null, + "x_query_params": [ + "limit", + "cursor", + "status", + "q" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_organizations_listEntitlements", + "title": "List org-wide (seat) grants", + "description": "List org-wide (seat) grants\n\nSame filters as GET /entitlements, scoped to this Organization.\n\nREST: GET /v1/organizations/{id}/entitlements", + "inputSchema": { + "additionalProperties": false, + "properties": { + "application_id": { + "type": "string" + }, + "cursor": { + "type": "string" + }, + "ends_before": { + "format": "date-time", + "type": "string" + }, + "id": { + "type": "string" + }, + "include_inactive": { + "type": "boolean" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "order_id": { + "type": "string" + }, + "status": { + "enum": [ + "active", + "disabled", + "expired", + "revoked" + ], + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List org-wide (seat) grants" + }, + "x_operation_id": "organizations.listEntitlements", + "x_method": "GET", + "x_path": "/v1/organizations/{id}/entitlements", + "x_path_params": [ + "id" + ], + "x_query_params": [ + "limit", + "cursor", + "application_id", + "status", + "order_id", + "ends_before", + "include_inactive" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_organizations_listMembers", + "title": "List members", + "description": "List members\n\nREST: GET /v1/organizations/{id}/members", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": "string" + }, + "id": { + "type": "string" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "membership_status": { + "enum": [ + "pending", + "active" + ], + "type": "string" + }, + "q": { + "type": "string" + }, + "role": { + "enum": [ + "org_admin", + "member" + ], + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List members" + }, + "x_operation_id": "organizations.listMembers", + "x_method": "GET", + "x_path": "/v1/organizations/{id}/members", + "x_path_params": [ + "id" + ], + "x_query_params": [ + "limit", + "cursor", + "role", + "membership_status", + "q" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_organizations_removeMember", + "title": "Remove a member or withdraw an invitation; or leave or decline (userId = me)", + "description": "Remove a member or withdraw an invitation; or leave or decline (userId = me)\n\nREST: DELETE /v1/organizations/{id}/members/{userId}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "userId": { + "description": "A usr_ id, or 'me' to leave", + "type": "string" + } + }, + "required": [ + "id", + "userId" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Remove a member or withdraw an invitation; or leave or decline (userId = me)" + }, + "x_operation_id": "organizations.removeMember", + "x_method": "DELETE", + "x_path": "/v1/organizations/{id}/members/{userId}", + "x_path_params": [ + "id", + "userId" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_organizations_update", + "title": "Rename (org admin), or suspend/reactivate (organizations.manage)", + "description": "Rename (org admin), or suspend/reactivate (organizations.manage)\n\nREST: PATCH /v1/organizations/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "reason": { + "maxLength": 2000, + "type": "string" + }, + "status": { + "enum": [ + "active", + "suspended" + ], + "type": "string" + } + }, + "type": "object" + }, + "id": { + "type": "string" + }, + "if_match": { + "description": "ETag from a prior read; a mismatch returns 409 version_conflict.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Rename (org admin), or suspend/reactivate (organizations.manage)" + }, + "x_operation_id": "organizations.update", + "x_method": "PATCH", + "x_path": "/v1/organizations/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": true + }, + { + "name": "substratal_organizations_updateMember", + "title": "Change a member's role", + "description": "Change a member's role\n\nREST: PATCH /v1/organizations/{id}/members/{userId}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "role": { + "enum": [ + "org_admin", + "member" + ], + "type": "string" + } + }, + "required": [ + "role" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "userId": { + "description": "A usr_ id, or 'me' to leave", + "type": "string" + } + }, + "required": [ + "id", + "userId", + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Change a member's role" + }, + "x_operation_id": "organizations.updateMember", + "x_method": "PATCH", + "x_path": "/v1/organizations/{id}/members/{userId}", + "x_path_params": [ + "id", + "userId" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_permissions_getEffective", + "title": "The live access check (always 200)", + "description": "The live access check (always 200)\n\nREST: GET /v1/users/{id}/apps/{appId}/effective-permissions", + "inputSchema": { + "additionalProperties": false, + "properties": { + "appId": { + "type": "string" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id", + "appId" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "The live access check (always 200)" + }, + "x_operation_id": "permissions.getEffective", + "x_method": "GET", + "x_path": "/v1/users/{id}/apps/{appId}/effective-permissions", + "x_path_params": [ + "id", + "appId" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_permissions_list", + "title": "List every Permission key the caller can see", + "description": "List every Permission key the caller can see\n\nREST: GET /v1/permissions", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": "string" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "scope": { + "description": "'platform' or an application_id", + "type": "string" + } + }, + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List every Permission key the caller can see" + }, + "x_operation_id": "permissions.list", + "x_method": "GET", + "x_path": "/v1/permissions", + "x_path_params": null, + "x_query_params": [ + "limit", + "cursor", + "scope" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_profiles_get", + "title": "Fetch global Profile", + "description": "Fetch global Profile\n\nREST: GET /v1/users/{id}/profile", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Fetch global Profile" + }, + "x_operation_id": "profiles.get", + "x_method": "GET", + "x_path": "/v1/users/{id}/profile", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_profiles_update", + "title": "Update global Profile (self or users.manage)", + "description": "Update global Profile (self or users.manage)\n\nREST: PATCH /v1/users/{id}/profile", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "avatar_url": { + "format": "uri", + "maxLength": 2048, + "type": [ + "string", + "null" + ] + }, + "contact_email": { + "format": "email", + "type": [ + "string", + "null" + ] + }, + "contact_phone": { + "pattern": "^\\+[1-9][0-9]{7,14}$", + "type": [ + "string", + "null" + ] + }, + "display_name": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + }, + "if_match": { + "description": "ETag from a prior read; a mismatch returns 409 version_conflict.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Update global Profile (self or users.manage)" + }, + "x_operation_id": "profiles.update", + "x_method": "PATCH", + "x_path": "/v1/users/{id}/profile", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": true + }, + { + "name": "substratal_roles_assign", + "title": "Assign a Role (idempotent — 200 if already held)", + "description": "Assign a Role (idempotent — 200 if already held)\n\nREST: POST /v1/users/{id}/roles/{roleId}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + }, + "roleId": { + "type": "string" + } + }, + "required": [ + "id", + "roleId" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Assign a Role (idempotent — 200 if already held)" + }, + "x_operation_id": "roles.assign", + "x_method": "POST", + "x_path": "/v1/users/{id}/roles/{roleId}", + "x_path_params": [ + "id", + "roleId" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_roles_create", + "title": "Define a Role", + "description": "Define a Role\n\nREST: POST /v1/roles", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "description": { + "maxLength": 2000, + "type": [ + "string", + "null" + ] + }, + "name": { + "pattern": "^[a-z][a-z0-9_]{1,40}$", + "type": "string" + }, + "permissions": { + "items": { + "type": "string" + }, + "type": "array" + }, + "scope": { + "description": "'platform' or an application_id", + "type": "string" + } + }, + "required": [ + "name", + "scope", + "permissions" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Define a Role" + }, + "x_operation_id": "roles.create", + "x_method": "POST", + "x_path": "/v1/roles", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_roles_delete", + "title": "Delete an unassigned, non-seed Role", + "description": "Delete an unassigned, non-seed Role\n\nREST: DELETE /v1/roles/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Delete an unassigned, non-seed Role" + }, + "x_operation_id": "roles.delete", + "x_method": "DELETE", + "x_path": "/v1/roles/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_roles_get", + "title": "Fetch one Role", + "description": "Fetch one Role\n\nREST: GET /v1/roles/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Fetch one Role" + }, + "x_operation_id": "roles.get", + "x_method": "GET", + "x_path": "/v1/roles/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_roles_list", + "title": "List Roles", + "description": "List Roles\n\nREST: GET /v1/roles", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": "string" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "scope": { + "description": "'platform' or an application_id", + "type": "string" + } + }, + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Roles" + }, + "x_operation_id": "roles.list", + "x_method": "GET", + "x_path": "/v1/roles", + "x_path_params": null, + "x_query_params": [ + "limit", + "cursor", + "scope" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_roles_listForUser", + "title": "List a user's Role assignments (including implicit default_app_role)", + "description": "List a user's Role assignments (including implicit default_app_role)\n\nREST: GET /v1/users/{id}/roles", + "inputSchema": { + "additionalProperties": false, + "properties": { + "application_id": { + "type": "string" + }, + "cursor": { + "type": "string" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List a user's Role assignments (including implicit default_app_role)" + }, + "x_operation_id": "roles.listForUser", + "x_method": "GET", + "x_path": "/v1/users/{id}/roles", + "x_path_params": [ + "id" + ], + "x_query_params": [ + "limit", + "cursor", + "application_id" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_roles_remove", + "title": "Remove a Role assignment (idempotent)", + "description": "Remove a Role assignment (idempotent)\n\nREST: DELETE /v1/users/{id}/roles/{roleId}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + }, + "roleId": { + "type": "string" + } + }, + "required": [ + "id", + "roleId" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Remove a Role assignment (idempotent)" + }, + "x_operation_id": "roles.remove", + "x_method": "DELETE", + "x_path": "/v1/users/{id}/roles/{roleId}", + "x_path_params": [ + "id", + "roleId" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_roles_update", + "title": "Change a Role's permissions or description", + "description": "Change a Role's permissions or description\n\nREST: PATCH /v1/roles/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "description": { + "maxLength": 2000, + "type": [ + "string", + "null" + ] + }, + "permissions": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "id": { + "type": "string" + }, + "if_match": { + "description": "ETag from a prior read; a mismatch returns 409 version_conflict.", + "type": "string" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Change a Role's permissions or description" + }, + "x_operation_id": "roles.update", + "x_method": "PATCH", + "x_path": "/v1/roles/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": true + }, + { + "name": "substratal_settings_get", + "title": "Fetch global Settings (self, or platform users.list)", + "description": "Fetch global Settings (self, or platform users.list)\n\nREST: GET /v1/users/{id}/settings", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Fetch global Settings (self, or platform users.list)" + }, + "x_operation_id": "settings.get", + "x_method": "GET", + "x_path": "/v1/users/{id}/settings", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_settings_update", + "title": "Update global Settings (self or users.manage)", + "description": "Update global Settings (self or users.manage)\n\nREST: PATCH /v1/users/{id}/settings", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "locale": { + "type": "string" + }, + "notifications": { + "additionalProperties": { + "type": [ + "boolean", + "null" + ] + }, + "type": "object" + }, + "theme": { + "enum": [ + "light", + "dark", + "system" + ], + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "type": "object" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + }, + "if_match": { + "description": "ETag from a prior read; a mismatch returns 409 version_conflict.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Update global Settings (self or users.manage)" + }, + "x_operation_id": "settings.update", + "x_method": "PATCH", + "x_path": "/v1/users/{id}/settings", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": true + }, + { + "name": "substratal_tenants_get", + "title": "Fetch one Tenant", + "description": "Fetch one Tenant\n\nREST: GET /v1/tenants/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Fetch one Tenant" + }, + "x_operation_id": "tenants.get", + "x_method": "GET", + "x_path": "/v1/tenants/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_tenants_getTierChangeRequest", + "title": "Fetch one tier-change request", + "description": "Fetch one tier-change request\n\nREST: GET /v1/tenants/{id}/tier-change-requests/{requestId}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "requestId": { + "type": "string" + } + }, + "required": [ + "id", + "requestId" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Fetch one tier-change request" + }, + "x_operation_id": "tenants.getTierChangeRequest", + "x_method": "GET", + "x_path": "/v1/tenants/{id}/tier-change-requests/{requestId}", + "x_path_params": [ + "id", + "requestId" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_tenants_list", + "title": "List Tenants (your own; all with tenants.manage)", + "description": "List Tenants (your own; all with tenants.manage)\n\nREST: GET /v1/tenants", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": "string" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "owner_organization_id": { + "type": "string" + }, + "owner_user_id": { + "type": "string" + }, + "plan": { + "description": "Commercial plan, independent of tier. isolated/dedicated_region require enterprise; starter requires a user owner.", + "enum": [ + "starter", + "team", + "enterprise" + ], + "type": "string" + }, + "restricted": { + "type": "boolean" + }, + "status": { + "enum": [ + "active", + "migrating", + "suspended" + ], + "type": "string" + }, + "tier": { + "enum": [ + "shared", + "isolated", + "dedicated_region" + ], + "type": "string" + } + }, + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Tenants (your own; all with tenants.manage)" + }, + "x_operation_id": "tenants.list", + "x_method": "GET", + "x_path": "/v1/tenants", + "x_path_params": null, + "x_query_params": [ + "limit", + "cursor", + "plan", + "tier", + "status", + "restricted", + "owner_user_id", + "owner_organization_id" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_tenants_listTierChangeRequests", + "title": "List tier-change requests for a Tenant", + "description": "List tier-change requests for a Tenant\n\nREST: GET /v1/tenants/{id}/tier-change-requests", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": "string" + }, + "id": { + "type": "string" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List tier-change requests for a Tenant" + }, + "x_operation_id": "tenants.listTierChangeRequests", + "x_method": "GET", + "x_path": "/v1/tenants/{id}/tier-change-requests", + "x_path_params": [ + "id" + ], + "x_query_params": [ + "limit", + "cursor" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_tenants_requestTierChange", + "title": "Open a tier-change request on a customer's behalf (tenants.manage)", + "description": "Open a tier-change request on a customer's behalf (tenants.manage)\n\nREST: POST /v1/tenants/{id}/tier-change-requests", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "reason": { + "maxLength": 2000, + "type": "string" + }, + "requested_region": { + "enum": [ + "us-east-1", + "us-west-2", + "ca-central-1", + "eu-west-1", + "eu-central-1", + "ap-southeast-2" + ], + "type": "string" + }, + "requested_tier": { + "enum": [ + "isolated", + "dedicated_region" + ], + "type": "string" + } + }, + "required": [ + "requested_tier", + "reason" + ], + "type": "object" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Open a tier-change request on a customer's behalf (tenants.manage)" + }, + "x_operation_id": "tenants.requestTierChange", + "x_method": "POST", + "x_path": "/v1/tenants/{id}/tier-change-requests", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_tenants_updateTierChangeRequest", + "title": "Schedule, start, complete, or cancel a tier-change request (tenants.manage)", + "description": "Schedule, start, complete, or cancel a tier-change request (tenants.manage)\n\nREST: PATCH /v1/tenants/{id}/tier-change-requests/{requestId}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "notes": { + "maxLength": 2000, + "type": "string" + }, + "scheduled_for": { + "format": "date-time", + "type": "string" + }, + "status": { + "enum": [ + "scheduled", + "in_progress", + "completed", + "cancelled" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "if_match": { + "description": "ETag from a prior read; a mismatch returns 409 version_conflict.", + "type": "string" + }, + "requestId": { + "type": "string" + } + }, + "required": [ + "id", + "requestId", + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Schedule, start, complete, or cancel a tier-change request (tenants.manage)" + }, + "x_operation_id": "tenants.updateTierChangeRequest", + "x_method": "PATCH", + "x_path": "/v1/tenants/{id}/tier-change-requests/{requestId}", + "x_path_params": [ + "id", + "requestId" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": true + }, + { + "name": "substratal_users_cancelErasure", + "title": "Cancel a scheduled erasure inside its 7-day window (users.manage)", + "description": "Cancel a scheduled erasure inside its 7-day window (users.manage)\n\nREST: DELETE /v1/users/{id}/erasure-requests/current", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Cancel a scheduled erasure inside its 7-day window (users.manage)" + }, + "x_operation_id": "users.cancelErasure", + "x_method": "DELETE", + "x_path": "/v1/users/{id}/erasure-requests/current", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_users_create", + "title": "Create (invite) a user", + "description": "Create (invite) a user\n\nRequires users.manage. Also creates a default Profile and Settings and assigns the member platform role, in one transaction.\n\nREST: POST /v1/users", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "display_name": { + "maxLength": 100, + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + }, + "send_invitation": { + "default": true, + "type": "boolean" + }, + "status": { + "default": "invited", + "enum": [ + "invited", + "active" + ], + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create (invite) a user" + }, + "x_operation_id": "users.create", + "x_method": "POST", + "x_path": "/v1/users", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_users_delete", + "title": "Soft-delete a user (self or users.manage)", + "description": "Soft-delete a user (self or users.manage)\n\nREST: DELETE /v1/users/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Soft-delete a user (self or users.manage)" + }, + "x_operation_id": "users.delete", + "x_method": "DELETE", + "x_path": "/v1/users/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_users_export", + "title": "Export everything this API holds about this user", + "description": "Export everything this API holds about this user\n\nSelf, or users.manage. GDPR Article 20 / CCPA right-to-know. Covers every table the hard-delete cascade clears, plus global Settings, which the cascade leaves in place.\n\nREST: GET /v1/users/{id}/export", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Export everything this API holds about this user" + }, + "x_operation_id": "users.export", + "x_method": "GET", + "x_path": "/v1/users/{id}/export", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_users_get", + "title": "Fetch one user", + "description": "Fetch one user\n\nREST: GET /v1/users/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Fetch one user" + }, + "x_operation_id": "users.get", + "x_method": "GET", + "x_path": "/v1/users/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_users_list", + "title": "List or search users", + "description": "List or search users\n\nRequires users.list. An app-scoped key with users.list is forced to application_id=\u003cits app\u003e.\n\nREST: GET /v1/users", + "inputSchema": { + "additionalProperties": false, + "properties": { + "application_id": { + "type": "string" + }, + "created_after": { + "format": "date-time", + "type": "string" + }, + "created_before": { + "format": "date-time", + "type": "string" + }, + "cursor": { + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "organization_id": { + "type": "string" + }, + "q": { + "minLength": 3, + "type": "string" + }, + "status": { + "description": "deleted is allowed for users.manage only", + "enum": [ + "active", + "invited", + "suspended", + "deleted" + ], + "type": "string" + } + }, + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List or search users" + }, + "x_operation_id": "users.list", + "x_method": "GET", + "x_path": "/v1/users", + "x_path_params": null, + "x_query_params": [ + "limit", + "cursor", + "status", + "email", + "q", + "application_id", + "organization_id", + "created_after", + "created_before" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_users_requestErasure", + "title": "Request right-to-erasure (soft-delete now, hard-delete cascade in 7 days)", + "description": "Request right-to-erasure (soft-delete now, hard-delete cascade in 7 days)\n\nREST: POST /v1/users/{id}/erasure-requests", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "reason": { + "maxLength": 2000, + "type": "string" + } + }, + "type": "object" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Request right-to-erasure (soft-delete now, hard-delete cascade in 7 days)" + }, + "x_operation_id": "users.requestErasure", + "x_method": "POST", + "x_path": "/v1/users/{id}/erasure-requests", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_users_resendInvitation", + "title": "Re-send the invitation email to an invited user (users.manage)", + "description": "Re-send the invitation email to an invited user (users.manage)\n\nREST: POST /v1/users/{id}/invitation", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Re-send the invitation email to an invited user (users.manage)" + }, + "x_operation_id": "users.resendInvitation", + "x_method": "POST", + "x_path": "/v1/users/{id}/invitation", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_users_suspend", + "title": "Suspend a user (users.manage)", + "description": "Suspend a user (users.manage)\n\nREST: POST /v1/users/{id}/suspend", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "reason": { + "maxLength": 2000, + "type": "string" + } + }, + "type": "object" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Suspend a user (users.manage)" + }, + "x_operation_id": "users.suspend", + "x_method": "POST", + "x_path": "/v1/users/{id}/suspend", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_users_update", + "title": "Update email (self, pending verification) or status (users.manage)", + "description": "Update email (self, pending verification) or status (users.manage)\n\nREST: PATCH /v1/users/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "status": { + "description": "users.manage only. active on a deleted User restores it.", + "enum": [ + "active", + "suspended" + ], + "type": "string" + } + }, + "type": "object" + }, + "id": { + "description": "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys).", + "type": "string" + }, + "if_match": { + "description": "ETag from a prior read; a mismatch returns 409 version_conflict.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Update email (self, pending verification) or status (users.manage)" + }, + "x_operation_id": "users.update", + "x_method": "PATCH", + "x_path": "/v1/users/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": true + }, + { + "name": "substratal_webhooks_create", + "title": "Subscribe a URL to event types", + "description": "Subscribe a URL to event types\n\nREST: POST /v1/webhooks", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "description": { + "maxLength": 255, + "type": "string" + }, + "events": { + "items": { + "oneOf": [ + { + "enum": [ + "access.granted", + "access.revoked", + "entitlement.granted", + "entitlement.disabled", + "entitlement.revoked", + "entitlement.expired", + "entitlement.updated", + "entitlement.member_scope_changed", + "entitlement.deleted", + "role.assigned", + "role.removed", + "user.suspended", + "user.reactivated", + "user.deleted", + "organization.member_added", + "organization.member_removed", + "application.review_status_changed", + "webhook.test" + ], + "type": "string" + }, + { + "const": "*", + "type": "string" + } + ] + }, + "minItems": 1, + "type": "array" + }, + "scope": { + "description": "An application_id, or 'platform' (webhooks.manage only). Required for an owner's User token or a webhooks.manage caller; optional for an app key, where it defaults to (and must equal) the key's own Application.", + "type": "string" + }, + "url": { + "format": "uri", + "type": "string" + } + }, + "required": [ + "url", + "events" + ], + "type": "object" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Subscribe a URL to event types" + }, + "x_operation_id": "webhooks.create", + "x_method": "POST", + "x_path": "/v1/webhooks", + "x_path_params": null, + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_webhooks_delete", + "title": "Unsubscribe", + "description": "Unsubscribe\n\nREST: DELETE /v1/webhooks/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Unsubscribe" + }, + "x_operation_id": "webhooks.delete", + "x_method": "DELETE", + "x_path": "/v1/webhooks/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_webhooks_get", + "title": "Fetch one subscription", + "description": "Fetch one subscription\n\nREST: GET /v1/webhooks/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Fetch one subscription" + }, + "x_operation_id": "webhooks.get", + "x_method": "GET", + "x_path": "/v1/webhooks/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_webhooks_list", + "title": "List subscriptions visible to the caller", + "description": "List subscriptions visible to the caller\n\nREST: GET /v1/webhooks", + "inputSchema": { + "additionalProperties": false, + "properties": { + "application_id": { + "type": "string" + }, + "cursor": { + "type": "string" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List subscriptions visible to the caller" + }, + "x_operation_id": "webhooks.list", + "x_method": "GET", + "x_path": "/v1/webhooks", + "x_path_params": null, + "x_query_params": [ + "limit", + "cursor", + "application_id" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_webhooks_listDeliveries", + "title": "Recent delivery attempts (30-day log)", + "description": "Recent delivery attempts (30-day log)\n\nREST: GET /v1/webhooks/{id}/deliveries", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "limit": { + "default": 25, + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "since": { + "format": "date-time", + "type": "string" + }, + "status": { + "enum": [ + "pending", + "succeeded", + "failed" + ], + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Recent delivery attempts (30-day log)" + }, + "x_operation_id": "webhooks.listDeliveries", + "x_method": "GET", + "x_path": "/v1/webhooks/{id}/deliveries", + "x_path_params": [ + "id" + ], + "x_query_params": [ + "limit", + "cursor", + "event_id", + "status", + "since" + ], + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_webhooks_redeliver", + "title": "Re-send one past event (same event id)", + "description": "Re-send one past event (same event id)\n\nREST: POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver", + "inputSchema": { + "additionalProperties": false, + "properties": { + "deliveryId": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "deliveryId" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Re-send one past event (same event id)" + }, + "x_operation_id": "webhooks.redeliver", + "x_method": "POST", + "x_path": "/v1/webhooks/{id}/deliveries/{deliveryId}/redeliver", + "x_path_params": [ + "id", + "deliveryId" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_webhooks_rotateSecret", + "title": "Issue a new signing secret (24-hour overlap)", + "description": "Issue a new signing secret (24-hour overlap)\n\nREST: POST /v1/webhooks/{id}/rotate-secret", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Issue a new signing secret (24-hour overlap)" + }, + "x_operation_id": "webhooks.rotateSecret", + "x_method": "POST", + "x_path": "/v1/webhooks/{id}/rotate-secret", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_webhooks_sendTest", + "title": "Send a webhook.test event now", + "description": "Send a webhook.test event now\n\nREST: POST /v1/webhooks/{id}/test", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Send a webhook.test event now" + }, + "x_operation_id": "webhooks.sendTest", + "x_method": "POST", + "x_path": "/v1/webhooks/{id}/test", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_webhooks_update", + "title": "Change url, events, description, api_version, or re-enable", + "description": "Change url, events, description, api_version, or re-enable\n\nREST: PATCH /v1/webhooks/{id}", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "properties": { + "api_version": { + "type": "string" + }, + "description": { + "maxLength": 255, + "type": "string" + }, + "events": { + "items": { + "type": "string" + }, + "type": "array" + }, + "status": { + "enum": [ + "healthy" + ], + "type": "string" + }, + "url": { + "format": "uri", + "type": "string" + } + }, + "type": "object" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Change url, events, description, api_version, or re-enable" + }, + "x_operation_id": "webhooks.update", + "x_method": "PATCH", + "x_path": "/v1/webhooks/{id}", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": true, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + } + ], + "resources": [ + { + "uri": "https://adron.github.io/substratalapps.com/llms.txt", + "name": "llms.txt", + "description": "Machine-oriented index of every page of the Substratal Apps API specification.", + "mimeType": "text/markdown", + "text": "# Substratal Apps — Platform API\n\n\u003e Substratal Apps is the user/organization/tenancy/settings/storage layer an\n\u003e app developer would otherwise have to build themselves — comparable in\n\u003e shape to Auth0, Clerk, or WorkOS, scoped around everything a multi-tenant\n\u003e app needs: accounts, roles and permissions, per-app settings, team/tenancy\n\u003e structure, and per-user storage, all behind one API. This site is the\n\u003e system of record for that API: users, roles, per-app access (the \"turn an\n\u003e app on/off for a user\" switch), profiles, and settings across every\n\u003e Application built on the platform. It is API-first: no user-facing\n\u003e interface is specified here — a dashboard for easier onboarding is\n\u003e planned, but it is a separate, later client of this API. Billing is each\n\u003e Application developer's own concern; this API tracks entitlement state,\n\u003e it does not process payments.\n\nThis site is the living specification. Treat it as authoritative over any\nolder single-file draft found elsewhere in the repository's history.\n\n## Start here\n\n- [Home](https://adron.github.io/substratalapps.com/): what Substratal Apps is and how this site is organized.\n- [Getting Started](https://adron.github.io/substratalapps.com/getting-started/): the fastest path to understanding the system before writing code against it.\n- [Quickstart](https://adron.github.io/substratalapps.com/quickstart/): a runnable curl walkthrough of the core access model.\n- [Domain Model](https://adron.github.io/substratalapps.com/domain-model/): the entities — User, Organization, Tenant, Application, Role, Permission, Entitlement, Profile, Setting, Order, Audit Event.\n- [Tenancy](https://adron.github.io/substratalapps.com/domain-model/tenancy/): the infrastructure-placement/data-isolation boundary for an Application owner — shared, isolated, or dedicated-region. Distinct from Organization.\n- [Access Control](https://adron.github.io/substratalapps.com/access-control/): how Entitlement (is the app on?) and Role (what can they do?) combine to decide every request, including Organization-vs-User precedence when an org-wide grant and a User's own standing could otherwise conflict.\n- [Database Schema](https://adron.github.io/substratalapps.com/domain-model/database-schema/): Postgres types, constraints, and indexes behind every entity — for implementers, not API callers.\n\n## API reference\n\n- [Conventions](https://adron.github.io/substratalapps.com/api-reference/conventions/): base URL, auth, pagination, idempotency, error shape, ID format.\n- [Auth](https://adron.github.io/substratalapps.com/api-reference/auth/): signup, login, MFA, sessions, password/email lifecycle, and how an Application obtains its per-app JWT.\n- [Users](https://adron.github.io/substratalapps.com/api-reference/users/)\n- [Profiles](https://adron.github.io/substratalapps.com/api-reference/profiles/)\n- [Settings](https://adron.github.io/substratalapps.com/api-reference/settings/)\n- [Applications](https://adron.github.io/substratalapps.com/api-reference/applications/)\n- [Entitlements](https://adron.github.io/substratalapps.com/api-reference/entitlements/): the on/off switch for a user's access to an app.\n- [Roles \u0026 Permissions](https://adron.github.io/substratalapps.com/api-reference/roles-and-permissions/)\n- [Organizations](https://adron.github.io/substratalapps.com/api-reference/organizations/)\n- [Tenancy](https://adron.github.io/substratalapps.com/api-reference/tenancy/): fetch a Tenant and request a tier change — support-only today, no customer self-service.\n- [Billing](https://adron.github.io/substratalapps.com/api-reference/billing/): the Application owner's own Substratal subscription — plan, usage, Stripe Checkout and Customer Portal.\n- [Audit](https://adron.github.io/substratalapps.com/api-reference/audit/)\n- [Webhooks](https://adron.github.io/substratalapps.com/api-reference/webhooks/)\n- [API Keys](https://adron.github.io/substratalapps.com/api-reference/api-keys/): service-to-service credentials.\n- [openapi.yaml](https://adron.github.io/substratalapps.com/openapi.yaml): the same API surface as a machine-readable OpenAPI 3.1 document.\n\n## Also useful\n\n- [Workflows](https://adron.github.io/substratalapps.com/workflows/): purchase-to-access, admin revokes access, role changes, end to end.\n- [Trust Model](https://adron.github.io/substratalapps.com/trust-model/): how a separately-hosted downstream app verifies a user's access against this API.\n- [MCP Server](https://adron.github.io/substratalapps.com/mcp-server/): a Model Context Protocol server that lets an AI agent call this API as tools, generated from openapi.yaml, using the same Bearer credential as every other caller — no new auth model.\n- [Non-Functional Requirements](https://adron.github.io/substratalapps.com/non-functional-requirements/)\n- [Compliance \u0026 Data Protection](https://adron.github.io/substratalapps.com/compliance/): which of SOC 2, HIPAA, GDPR, and CCPA apply, and when.\n- [Roadmap](https://adron.github.io/substratalapps.com/roadmap/): MVP through Phase 3.\n- [Pricing](https://adron.github.io/substratalapps.com/pricing/): Starter (free), Team (per-seat), and Enterprise (with a choice of Tenant tier) — what Substratal itself charges the Application-owner developer, distinct from what that developer charges their own end users.\n- [Changelog](https://adron.github.io/substratalapps.com/changelog/): what's changed in this spec over time.\n- [Glossary](https://adron.github.io/substratalapps.com/glossary/)\n- [Versions](https://adron.github.io/substratalapps.com/versions/): every released version of this spec; older versions are frozen copies under `/versions/\u003cversion\u003e/`.\n\nDeployment/infrastructure, the build plan, and other project-planning material are intentionally not part of this API-specification site — they live as plain Markdown in the repository root at https://github.com/Adron/substratalapps.com, starting from its README.md.\n" + }, + { + "uri": "https://adron.github.io/substratalapps.com/glossary/", + "name": "Glossary", + "description": "Precise definitions for every term the API uses.", + "mimeType": "text/markdown", + "text": "# Glossary\n{: .no_toc }\n\nPrecise definitions for every term used across this site. If a page uses a capitalized term (`Entitlement`, `AppRole`) without defining it inline, it means exactly what's written here.\n{: .fs-6 .fw-300 }\n\n---\n\n#### Access path\nOne way a User reaches an Application: their own personal [Entitlement](#entitlement), or an org-wide grant from an [Organization](#organization) that includes them. Resolved access is the union of every active path. See [Access Control](../access-control/#the-algorithm).\n\n#### API Key\nA long-lived service credential (`satk_live_…`/`satk_test_…`), scoped to the platform or to one Application. An app-scoped key can only ever touch its own Application's rows. See [API Keys](../api-reference/api-keys/).\n\n#### App token\nThe short-lived (5-minute) JWT an Application verifies locally, with `aud` set to that Application. Carries `entitlement_status` and `effective_permissions`. See [Auth → The app token](../api-reference/auth/#the-app-token).\n\n#### Application\n(also \"App\") One developer's separately-hosted app (web, iOS, macOS, Windows, Linux, …) that uses Substratal Apps for its users, settings, organizations, tenancy, and storage. This is its catalog entry. See [Domain Model → Applications](../domain-model/applications/).\n\n#### AppProfile\nPer-app identity data for one user — e.g. a display handle meaningful only inside that one app. Distinct from the global [Profile](#profile).\n\n#### AppRole\nA [Role](#role) scoped to one Application, defined by that app's own catalog entry rather than by the platform. See [Domain Model → Roles \u0026 Permissions](../domain-model/roles-and-permissions/).\n\n#### AppSettings\nPer-app configuration for one user, layered over that user's global [Settings](#settings) and the Application's declared defaults. See [Workflows → Settings resolution](../workflows/#settings-resolution-in-practice).\n\n#### Audit Event\nAn immutable record of who changed what access, when. See [Domain Model → Orders \u0026 Audit](../domain-model/orders-and-audit/).\n\n#### Effective permissions\nThe resolved set of [Permissions](#permission) a user holds for a given Application — the union of the permissions on their app-scoped Roles for that app (explicit assignments plus the app's `default_app_role`), and empty unless the User and their Entitlement are both active. Only that app's own `app.\u003cslug\u003e.*` keys ever appear; platform Roles govern the hub, not apps. See [Access Control](../access-control/#step-2-effective-permissions).\n\n#### Entitlement\nThe join record between a User (or Organization) and an Application: do they own it, and is it currently switched on. The record behind \"turn an app on/off for a user.\" See [Domain Model → Entitlements](../domain-model/entitlements/).\n\n#### Hub\nShorthand used throughout this site for Substratal Apps itself — the platform this API belongs to.\n\n#### MCP\nModel Context Protocol — the standard this site's AI-agent-facing tool-call interface implements. See [MCP Server](../mcp-server/). Introduces no new authorization model of its own; see [Authentication — no new model](../mcp-server/#authentication--no-new-model).\n\n#### Order\nNot an entity of this API. It's the developer's own commerce record, in their own billing system. An [Entitlement](#entitlement) carries only an opaque `order_id` reference to it. See [Domain Model → Orders \u0026 Audit](../domain-model/orders-and-audit/#order).\n\n#### Organization\nA domain/grouping object for Users — a company, or a group within a company — used to organize shared admin standing and group-wide Application access. Decoupled from infrastructure placement; see [Tenant](#tenant) and [Tenant vs. Organization](../domain-model/tenancy/#tenant-vs-organization). Pulled into [Phase 2](../roadmap/#phase-2); see [Organizations](../domain-model/users-and-organizations/#organization).\n\n#### OrganizationMembership\nThe join record between a User and an Organization, carrying that User's standing *within* that one Organization (`org_admin` or `member`) and whether it's `pending` (invited, not yet accepted) or `active` — a User can hold this for any number of Organizations at once. See [Domain Model → Users \u0026 Organizations](../domain-model/users-and-organizations/#organizationmembership).\n\n#### Permission\nAn atomic, checkable capability, written as a dotted key (`billing.manage`, `app.timetrack.export`). Never assigned directly to a user — always granted through a [Role](#role).\n\n#### Plan\nThe commercial subscription on a [Tenant](#tenant): `starter`, `team`, or `enterprise`. Independent of [Tier](#tier). See [Pricing](../pricing/).\n\n#### PlatformRole\nA [Role](#role) that applies across the whole hub rather than one Application — `superadmin`, `support`, `billing_admin`, `member`.\n\n#### Profile\nGlobal, cross-app identity and contact data for a User — name, avatar, email, locale. Distinct from [AppProfile](#appprofile).\n\n#### Restricted\nThe state of a [Tenant](#tenant) whose subscription lapsed while its usage exceeded what it could drop to. Existing access keeps working; new usage returns `402`. See [Pricing → Subscription lapse](../pricing/#subscription-lapse--downgrades).\n\n#### Role\nA named bundle of [Permissions](#permission). Either a [PlatformRole](#platformrole) or an [AppRole](#approle).\n\n#### Seat\nA distinct User with active resolved access to at least one Application a [Tenant](#tenant) owns. The unit Team and Enterprise are billed on. See [Pricing](../pricing/#what-seat-means-here).\n\n#### Session\nOne successful login (`ses_…`). Every refresh token and app token minted from it carries its id as `sid`, and revoking it ends them all. See [Auth → Sessions](../api-reference/auth/#sessions).\n\n#### Settings\nGlobal, cross-app configuration for a User — locale, timezone, notification preferences. Distinct from [AppSettings](#appsettings).\n\n#### Tenant\nThe infrastructure-placement and data-isolation boundary for one of Substratal's own paying customers — the owner of an Application's catalog entry. `shared` (default), `isolated`, or `dedicated_region`. Not the same thing as [Organization](#organization) — see [Tenant vs. Organization](../domain-model/tenancy/#tenant-vs-organization). See [Domain Model → Tenancy](../domain-model/tenancy/).\n\n#### Tier\nWhere a [Tenant](#tenant)'s data physically lives: `shared`, `isolated`, or `dedicated_region`. Independent of [Plan](#plan), except that the non-`shared` tiers require Enterprise.\n\n#### User\nA person with an account on Substratal. One identity, used everywhere in the hub and, via the [Trust Model](../trust-model/), in every app they launch from it.\n\n#### Webhook\nA signed HTTPS POST the hub sends an Application when something changes. `access.revoked` and `role.removed` are required subscriptions. See [Webhooks](../api-reference/webhooks/).\n" + } + ] +} diff --git a/internal/platform/config/config.go b/internal/platform/config/config.go new file mode 100644 index 0000000..d6e9cb6 --- /dev/null +++ b/internal/platform/config/config.go @@ -0,0 +1,187 @@ +// Package config reads every setting from the environment (README → Local +// development documents each one in .env.example). Nothing outside +// internal/platform branches on the environment; it only reads Config. +package config + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// Config is the process configuration. +type Config struct { + Env string // local | test | production + + // Database. Backend "pg" uses DatabaseURL; "dataapi" uses the ARNs. + DatabaseBackend string + DatabaseURL string + DBClusterARN string + DBSecretARN string + DBName string + + // Public identity of the API. + Issuer string // JWT iss, e.g. https://api.substratalapps.com + PublicBaseURL string // where /v1 lives, e.g. https://api.substratalapps.com + AuthorizeURL string // hosted sign-in page (OAuth authorization endpoint) + DashboardURL string // links in emails (verify, reset, invitation) + ListenAddr string // devserver only + CORSOrigins []string + SigningKeyID string // KMS key id/ARN for RS256 JWT signing; empty = local key + LocalSigningKey string // path to a PEM RSA key for local signing (generated if missing) + DataKeyID string // KMS key id/ARN for encrypting TOTP/webhook secrets; empty = local key + LocalDataKey string // base64 32-byte AES key for local encryption + CursorSecret string // HMAC key for pagination cursors + + // Email: "smtp" (Mailpit locally), "ses", or "log". + EmailBackend string + SMTPAddr string + EmailFrom string + + // Webhooks: SQS queue the API nudges after committing an outbox event. + // Empty = the devserver's in-process worker polls instead. + WebhookQueueURL string + + // Stripe. + StripeSecretKey string + StripeWebhookSecret string + StripePrices StripePrices + + // AppSecretARN, when set, names a Secrets Manager secret holding a JSON + // object whose keys override the secret fields above (cursor_secret, + // stripe_secret_key, stripe_webhook_secret, local_data_key). Lambdas + // read secrets this way so they never sit in function configuration. + AppSecretARN string + + // Server-side hard limits. + RequestTimeout time.Duration +} + +// StripePrices are the Stripe Price ids the catalog sync script creates. +type StripePrices struct { + TeamBase string + TeamSeat string + IsolatedAddOn string + DedicatedAddOn string +} + +// Load reads Config from the environment, applying local defaults. +func Load() (Config, error) { + env := get("APP_ENV", "local") + c := Config{ + Env: env, + DatabaseBackend: get("DATABASE_BACKEND", "pg"), + DatabaseURL: get("DATABASE_URL", "postgres://substratal:substratal@localhost:55432/substratal?sslmode=disable"), + DBClusterARN: os.Getenv("DB_CLUSTER_ARN"), + DBSecretARN: os.Getenv("DB_SECRET_ARN"), + DBName: get("DB_NAME", "substratal"), + Issuer: get("ISSUER", "https://api.substratalapps.com"), + PublicBaseURL: get("PUBLIC_BASE_URL", "http://localhost:8080"), + AuthorizeURL: get("AUTHORIZE_URL", "https://auth.substratalapps.com/authorize"), + DashboardURL: get("DASHBOARD_URL", "http://localhost:3000"), + ListenAddr: get("LISTEN_ADDR", ":8080"), + CORSOrigins: list(os.Getenv("CORS_ALLOWED_ORIGINS")), + SigningKeyID: os.Getenv("KMS_SIGNING_KEY_ID"), + LocalSigningKey: get("LOCAL_SIGNING_KEY_FILE", ".dev/signing-key.pem"), + DataKeyID: os.Getenv("KMS_DATA_KEY_ID"), + LocalDataKey: os.Getenv("LOCAL_DATA_KEY"), + CursorSecret: os.Getenv("CURSOR_SECRET"), + EmailBackend: get("EMAIL_BACKEND", "smtp"), + SMTPAddr: get("SMTP_ADDR", "localhost:51025"), + EmailFrom: get("EMAIL_FROM", "Substratal "), + WebhookQueueURL: os.Getenv("WEBHOOK_QUEUE_URL"), + StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), + StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), + StripePrices: StripePrices{ + TeamBase: os.Getenv("STRIPE_PRICE_TEAM_BASE"), + TeamSeat: os.Getenv("STRIPE_PRICE_TEAM_SEAT"), + IsolatedAddOn: os.Getenv("STRIPE_PRICE_ISOLATED"), + DedicatedAddOn: os.Getenv("STRIPE_PRICE_DEDICATED_REGION"), + }, + AppSecretARN: os.Getenv("APP_SECRET_ARN"), + RequestTimeout: duration("REQUEST_TIMEOUT", 25*time.Second), + } + if c.Env == "production" { + if c.DatabaseBackend == "pg" && os.Getenv("DATABASE_BACKEND") == "" { + c.DatabaseBackend = "dataapi" + } + if c.EmailBackend == "smtp" && os.Getenv("EMAIL_BACKEND") == "" { + c.EmailBackend = "ses" + } + } + return c, nil +} + +// SecretFetcher reads a secret's string value (Secrets Manager in AWS). +type SecretFetcher func(ctx context.Context, arn string) (string, error) + +// ApplySecret overlays the JSON secret named by AppSecretARN, then checks +// that production has every secret it needs. +func (c *Config) ApplySecret(ctx context.Context, fetch SecretFetcher) error { + if c.AppSecretARN != "" { + raw, err := fetch(ctx, c.AppSecretARN) + if err != nil { + return fmt.Errorf("config: read app secret: %w", err) + } + var s map[string]string + if err := json.Unmarshal([]byte(raw), &s); err != nil { + return fmt.Errorf("config: app secret isn't a JSON object: %w", err) + } + set := func(dst *string, key string) { + if v, ok := s[key]; ok && v != "" { + *dst = v + } + } + set(&c.CursorSecret, "cursor_secret") + set(&c.StripeSecretKey, "stripe_secret_key") + set(&c.StripeWebhookSecret, "stripe_webhook_secret") + set(&c.LocalDataKey, "local_data_key") + } + if c.CursorSecret == "" { + if c.Env == "production" { + return fmt.Errorf("config: cursor_secret is required in production") + } + c.CursorSecret = "local-development-cursor-secret-not-for-production" + } + if c.Env == "production" { + if c.SigningKeyID == "" || c.DataKeyID == "" { + return fmt.Errorf("config: KMS_SIGNING_KEY_ID and KMS_DATA_KEY_ID are required in production") + } + } + return nil +} + +func get(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func list(v string) []string { + var out []string + for _, p := range strings.Split(v, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +func duration(key string, def time.Duration) time.Duration { + v := os.Getenv(key) + if v == "" { + return def + } + if d, err := time.ParseDuration(v); err == nil { + return d + } + if n, err := strconv.Atoi(v); err == nil { + return time.Duration(n) * time.Second + } + return def +} diff --git a/internal/platform/lambdahttp/lambdahttp.go b/internal/platform/lambdahttp/lambdahttp.go new file mode 100644 index 0000000..a3b43bd --- /dev/null +++ b/internal/platform/lambdahttp/lambdahttp.go @@ -0,0 +1,91 @@ +// Package lambdahttp adapts API Gateway HTTP API (payload v2) events to a +// standard http.Handler, so the exact handler the devserver runs on a +// laptop is what each Lambda runs in AWS (README → Technology stack). +package lambdahttp + +import ( + "bytes" + "context" + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "unicode/utf8" + + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" +) + +// Start runs h as the Lambda handler. It never returns. +func Start(h http.Handler) { + lambda.Start(Handler(h)) +} + +// Handler converts h into a Lambda handler function. +func Handler(h http.Handler) func(context.Context, events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) { + return func(ctx context.Context, ev events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) { + req, err := Request(ctx, ev) + if err != nil { + return events.APIGatewayV2HTTPResponse{StatusCode: http.StatusBadRequest}, nil + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return Response(rec), nil + } +} + +// Request builds the http.Request for an event. +func Request(ctx context.Context, ev events.APIGatewayV2HTTPRequest) (*http.Request, error) { + var body io.Reader = strings.NewReader(ev.Body) + if ev.IsBase64Encoded { + b, err := base64.StdEncoding.DecodeString(ev.Body) + if err != nil { + return nil, err + } + body = bytes.NewReader(b) + } + u := url.URL{Path: ev.RawPath, RawQuery: ev.RawQueryString} + req, err := http.NewRequestWithContext(ctx, ev.RequestContext.HTTP.Method, u.RequestURI(), body) + if err != nil { + return nil, err + } + for k, v := range ev.Headers { + // HTTP API joins repeated headers with commas. + for _, part := range []string{v} { + req.Header.Add(k, part) + } + } + for _, c := range ev.Cookies { + req.Header.Add("Cookie", c) + } + req.Host = ev.RequestContext.DomainName + // API Gateway's view of the client is the trustworthy one. + req.RemoteAddr = ev.RequestContext.HTTP.SourceIP + ":0" + if req.Header.Get("X-Request-Id") == "" && ev.RequestContext.RequestID != "" { + req.Header.Set("X-Amzn-Request-Id", ev.RequestContext.RequestID) + } + return req, nil +} + +// Response converts a recorded response. +func Response(rec *httptest.ResponseRecorder) events.APIGatewayV2HTTPResponse { + headers := map[string]string{} + var cookies []string + for k, v := range rec.Header() { + if strings.EqualFold(k, "Set-Cookie") { + cookies = append(cookies, v...) + continue + } + headers[k] = strings.Join(v, ",") + } + body := rec.Body.Bytes() + out := events.APIGatewayV2HTTPResponse{StatusCode: rec.Code, Headers: headers, Cookies: cookies} + if utf8.Valid(body) { + out.Body = string(body) + } else { + out.Body, out.IsBase64Encoded = base64.StdEncoding.EncodeToString(body), true + } + return out +} diff --git a/internal/platform/lambdahttp/lambdahttp_test.go b/internal/platform/lambdahttp/lambdahttp_test.go new file mode 100644 index 0000000..04e4b02 --- /dev/null +++ b/internal/platform/lambdahttp/lambdahttp_test.go @@ -0,0 +1,30 @@ +package lambdahttp + +import ( + "context" + "io" + "net/http" + "testing" + + "github.com/aws/aws-lambda-go/events" +) + +func TestAdapter(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + w.Header().Set("X-Seen", r.Method+" "+r.URL.RequestURI()+" "+r.Header.Get("Authorization")+" "+r.RemoteAddr) + w.WriteHeader(201) + _, _ = w.Write(b) + }) + ev := events.APIGatewayV2HTTPRequest{RawPath: "/v1/users", RawQueryString: "limit=2", Body: `{"a":1}`, + Headers: map[string]string{"authorization": "Bearer x"}} + ev.RequestContext.HTTP.Method = "POST" + ev.RequestContext.HTTP.SourceIP = "203.0.113.24" + resp, err := Handler(h)(context.Background(), ev) + if err != nil || resp.StatusCode != 201 || resp.Body != `{"a":1}` { + t.Fatalf("resp = %+v %v", resp, err) + } + if resp.Headers["X-Seen"] != "POST /v1/users?limit=2 Bearer x 203.0.113.24:0" { + t.Fatalf("seen = %s", resp.Headers["X-Seen"]) + } +} diff --git a/internal/platform/wire/db.go b/internal/platform/wire/db.go new file mode 100644 index 0000000..d1906a8 --- /dev/null +++ b/internal/platform/wire/db.go @@ -0,0 +1,43 @@ +// Package wire builds the concrete dependencies (database, signer, +// encrypter, email, queue) from Config. It's the only place that knows +// which implementation each environment uses. +package wire + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/rdsdata" + + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/db/dataapi" + "github.com/Adron/substratalapps.com/internal/db/pg" + "github.com/Adron/substratalapps.com/internal/platform/config" +) + +// AWS loads the default AWS SDK config. AWS_ENDPOINT_URL (LocalStack) is +// honored by the SDK itself, so local and real AWS share this code path. +func AWS(ctx context.Context) (aws.Config, error) { + return awsconfig.LoadDefaultConfig(ctx) +} + +// Database opens the configured backend. +func Database(ctx context.Context, c config.Config) (db.DB, error) { + switch c.DatabaseBackend { + case "pg": + return pg.Open(ctx, c.DatabaseURL) + case "dataapi": + if c.DBClusterARN == "" || c.DBSecretARN == "" { + return nil, fmt.Errorf("wire: dataapi backend needs DB_CLUSTER_ARN and DB_SECRET_ARN") + } + awsCfg, err := AWS(ctx) + if err != nil { + return nil, err + } + return dataapi.New(rdsdata.NewFromConfig(awsCfg), c.DBClusterARN, c.DBSecretARN, c.DBName), nil + default: + return nil, fmt.Errorf("wire: unknown DATABASE_BACKEND %q", c.DatabaseBackend) + } +} diff --git a/internal/platform/wire/deps.go b/internal/platform/wire/deps.go new file mode 100644 index 0000000..a957682 --- /dev/null +++ b/internal/platform/wire/deps.go @@ -0,0 +1,182 @@ +package wire + +import ( + "context" + "encoding/base64" + "fmt" + "log/slog" + "os" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/aws/aws-sdk-go-v2/service/sesv2" + "github.com/aws/aws-sdk-go-v2/service/sqs" + + "github.com/Adron/substratalapps.com/internal/auth" + "github.com/Adron/substratalapps.com/internal/billing" + "github.com/Adron/substratalapps.com/internal/core" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/email" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/platform/config" + "github.com/Adron/substratalapps.com/internal/webhooks" +) + +// Logger returns the process's structured JSON logger. +func Logger() *slog.Logger { + level := slog.LevelInfo + if os.Getenv("LOG_LEVEL") == "debug" { + level = slog.LevelDebug + } + return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})) +} + +// LoadConfig reads Config and overlays the app secret from Secrets Manager. +func LoadConfig(ctx context.Context) (config.Config, error) { + cfg, err := config.Load() + if err != nil { + return cfg, err + } + err = cfg.ApplySecret(ctx, func(ctx context.Context, arn string) (string, error) { + awsCfg, err := AWS(ctx) + if err != nil { + return "", err + } + out, err := secretsmanager.NewFromConfig(awsCfg).GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{SecretId: &arn}) + if err != nil { + return "", err + } + if out.SecretString == nil { + return "", fmt.Errorf("secret %s has no string value", arn) + } + return *out.SecretString, nil + }) + return cfg, err +} + +// Signer builds the JWT signer: KMS when configured, else a local key. +func Signer(ctx context.Context, cfg config.Config) (auth.Signer, error) { + if cfg.SigningKeyID == "" { + return auth.NewLocalSigner(cfg.LocalSigningKey) + } + awsCfg, err := AWS(ctx) + if err != nil { + return nil, err + } + var published []string + for _, k := range strings.Split(os.Getenv("KMS_PUBLISHED_KEY_IDS"), ",") { + if k = strings.TrimSpace(k); k != "" { + published = append(published, k) + } + } + return auth.NewKMSSigner(ctx, kms.NewFromConfig(awsCfg), cfg.SigningKeyID, published) +} + +// Encrypter builds the secret encrypter: KMS when configured, else local. +func Encrypter(ctx context.Context, cfg config.Config) (auth.Encrypter, error) { + if cfg.DataKeyID == "" { + return auth.NewLocalEncrypter(cfg.LocalDataKey) + } + awsCfg, err := AWS(ctx) + if err != nil { + return nil, err + } + return auth.NewKMSEncrypter(kms.NewFromConfig(awsCfg), cfg.DataKeyID), nil +} + +// Email builds the sender for EMAIL_BACKEND. +func Email(ctx context.Context, cfg config.Config) (email.Sender, error) { + switch cfg.EmailBackend { + case "smtp": + return email.SMTP{Addr: cfg.SMTPAddr, From: cfg.EmailFrom}, nil + case "ses": + awsCfg, err := AWS(ctx) + if err != nil { + return nil, err + } + return email.SES{Client: sesv2.NewFromConfig(awsCfg), From: cfg.EmailFrom}, nil + case "log": + return email.Log{}, nil + } + return nil, fmt.Errorf("wire: unknown EMAIL_BACKEND %q", cfg.EmailBackend) +} + +// Billing builds the Stripe client, or nil when no key is configured +// (checkout/portal then answer 503 billing_unavailable). +func Billing(cfg config.Config, log *slog.Logger) core.Billing { + if cfg.StripeSecretKey == "" { + return nil + } + return billing.New(cfg.StripeSecretKey, cfg.StripePrices, log) +} + +// Base is everything every entry point shares. +type Base struct { + Config config.Config + Log *slog.Logger + DB db.DB + Signer auth.Signer + Encrypter auth.Encrypter + Email email.Sender +} + +// NewBase builds the shared dependencies. +func NewBase(ctx context.Context) (*Base, error) { + log := Logger() + cfg, err := LoadConfig(ctx) + if err != nil { + return nil, err + } + d, err := Database(ctx, cfg) + if err != nil { + return nil, err + } + signer, err := Signer(ctx, cfg) + if err != nil { + return nil, err + } + enc, err := Encrypter(ctx, cfg) + if err != nil { + return nil, err + } + mail, err := Email(ctx, cfg) + if err != nil { + return nil, err + } + return &Base{Config: cfg, Log: log, DB: d, Signer: signer, Encrypter: enc, Email: mail}, nil +} + +// Deliverer is the webhook delivery engine. +func (b *Base) Deliverer() *webhooks.Deliverer { + d := webhooks.New(b.DB, b.Encrypter, b.Email, b.Log) + d.AllowPrivate = b.Config.Env != "production" && os.Getenv("WEBHOOKS_ALLOW_PRIVATE") == "true" + return d +} + +// Notifier is SQS when WEBHOOK_QUEUE_URL is set (AWS), or nil. +func (b *Base) Notifier(ctx context.Context) (core.Notifier, error) { + if b.Config.WebhookQueueURL == "" { + return nil, nil + } + awsCfg, err := AWS(ctx) + if err != nil { + return nil, err + } + return webhooks.SQSNotifier{Client: sqs.NewFromConfig(awsCfg), QueueURL: b.Config.WebhookQueueURL, Log: b.Log}, nil +} + +// Server builds the API with the given notifier. +func (b *Base) Server(n core.Notifier) *core.Server { + key := []byte(b.Config.CursorSecret) + if raw, err := base64.StdEncoding.DecodeString(b.Config.CursorSecret); err == nil && len(raw) >= 16 { + key = raw + } + core.AllowPrivateWebhookTargets = b.Config.Env != "production" && os.Getenv("WEBHOOKS_ALLOW_PRIVATE") == "true" + return core.New(core.Deps{ + DB: b.DB, Signer: b.Signer, Encrypter: b.Encrypter, Email: b.Email, Notifier: n, + Billing: Billing(b.Config, b.Log), Cursors: httpx.Cursors{Key: key}, Log: b.Log, + Issuer: b.Config.Issuer, PublicBaseURL: b.Config.PublicBaseURL, AuthorizeURL: b.Config.AuthorizeURL, + DashboardURL: b.Config.DashboardURL, CORSOrigins: b.Config.CORSOrigins, + }) +} diff --git a/internal/schema/schema.go b/internal/schema/schema.go new file mode 100644 index 0000000..43c55a4 --- /dev/null +++ b/internal/schema/schema.go @@ -0,0 +1,350 @@ +// Package schema implements the JSON Schema subset an Application's +// settings_schema may use (Domain Model → Settings → settings_schema +// rules): draft 2020-12, root {"type":"object","properties":{...}}, +// additionalProperties always false, ≤200 properties, ≤64 KB, property +// names ^[a-z][a-z0-9_]{0,62}$, types string (enum, format date-time, +// maxLength), boolean, integer, number (minimum/maximum), object and +// array; in-document $ref only; defaults must validate; x-pii marks +// personal data. +package schema + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "regexp" + "strings" + "time" + "unicode/utf8" +) + +// Reserved global settings keys an Application may not declare. +var Reserved = []string{"locale", "timezone", "theme", "notifications"} + +// IsReserved reports whether key is a reserved global key. +func IsReserved(key string) bool { + for _, r := range Reserved { + if r == key { + return true + } + } + return false +} + +// Issue is one schema-rule or value failure. +type Issue struct { + Path string `json:"field"` + Code string `json:"code"` + Max *float64 `json:"max,omitempty"` + Min *float64 `json:"min,omitempty"` + Allowed []string `json:"allowed,omitempty"` + Pattern string `json:"pattern,omitempty"` + Message string `json:"message,omitempty"` +} + +// Prop is one resolved property schema. +type Prop struct { + Type string + Enum []json.RawMessage + Format string + MaxLength *int + Minimum *float64 + Maximum *float64 + Default json.RawMessage + PII bool + Items *Prop + Properties map[string]*Prop +} + +// Schema is a parsed settings_schema. +type Schema struct { + Props map[string]*Prop + Order []string +} + +var propName = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`) + +const ( + maxBytes = 64 << 10 + maxProps = 200 + maxDepth = 8 +) + +// Parse checks raw against the settings_schema rules. A nil raw (absent) +// is the empty schema. Issues are reported per path; reserved holds any +// reserved global keys the schema declares (422 reserved_settings_key). +func Parse(raw json.RawMessage) (s *Schema, issues []Issue, reserved []string) { + s = &Schema{Props: map[string]*Prop{}} + if len(bytes.TrimSpace(raw)) == 0 { + return s, nil, nil + } + if len(raw) > maxBytes { + return s, []Issue{{Path: "settings_schema", Code: "too_long", Max: f64(maxBytes), Message: "at most 64 KB serialized"}}, nil + } + var root map[string]json.RawMessage + if err := json.Unmarshal(raw, &root); err != nil { + return s, []Issue{{Path: "settings_schema", Code: "invalid_format", Message: "must be a JSON object"}}, nil + } + if t := str(root["type"]); t != "object" { + issues = append(issues, Issue{Path: "settings_schema.type", Code: "enum_mismatch", Allowed: []string{"object"}}) + } + var props map[string]json.RawMessage + if p, ok := root["properties"]; ok { + if err := json.Unmarshal(p, &props); err != nil { + issues = append(issues, Issue{Path: "settings_schema.properties", Code: "invalid_format", Message: "must be an object"}) + } + } + if len(props) > maxProps { + issues = append(issues, Issue{Path: "settings_schema.properties", Code: "too_long", Max: f64(maxProps)}) + } + p := parser{root: raw} + for _, name := range sortedKeys(props) { + path := "settings_schema.properties." + name + if IsReserved(name) { + reserved = append(reserved, name) + continue + } + if !propName.MatchString(name) { + issues = append(issues, Issue{Path: path, Code: "invalid_format", Pattern: propName.String()}) + continue + } + prop, errs := p.prop(props[name], path, 0) + issues = append(issues, errs...) + if prop != nil { + s.Props[name] = prop + s.Order = append(s.Order, name) + } + } + return s, issues, reserved +} + +type parser struct{ root json.RawMessage } + +func (p parser) prop(raw json.RawMessage, path string, depth int) (*Prop, []Issue) { + if depth > maxDepth { + return nil, []Issue{{Path: path, Code: "invalid_format", Message: "nested too deeply"}} + } + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return nil, []Issue{{Path: path, Code: "invalid_format", Message: "must be a schema object"}} + } + if ref, ok := m["$ref"]; ok { + target, err := p.resolve(str(ref)) + if err != nil { + return nil, []Issue{{Path: path + ".$ref", Code: "invalid_format", Message: err.Error()}} + } + return p.prop(target, path, depth+1) + } + var issues []Issue + prop := &Prop{Type: str(m["type"]), Format: str(m["format"]), Default: m["default"]} + switch prop.Type { + case "string", "boolean", "integer", "number", "object", "array": + default: + return nil, []Issue{{Path: path + ".type", Code: "enum_mismatch", + Allowed: []string{"string", "boolean", "integer", "number", "object", "array"}}} + } + if e, ok := m["enum"]; ok { + if err := json.Unmarshal(e, &prop.Enum); err != nil || len(prop.Enum) == 0 { + issues = append(issues, Issue{Path: path + ".enum", Code: "invalid_format"}) + } + } + if prop.Format != "" && !(prop.Type == "string" && prop.Format == "date-time") { + issues = append(issues, Issue{Path: path + ".format", Code: "enum_mismatch", Allowed: []string{"date-time"}}) + } + if v, ok := m["maxLength"]; ok { + var n int + if json.Unmarshal(v, &n) != nil || n < 0 || prop.Type != "string" { + issues = append(issues, Issue{Path: path + ".maxLength", Code: "invalid_format"}) + } else { + prop.MaxLength = &n + } + } + for _, k := range []string{"minimum", "maximum"} { + if v, ok := m[k]; ok { + var n float64 + if json.Unmarshal(v, &n) != nil || (prop.Type != "integer" && prop.Type != "number") { + issues = append(issues, Issue{Path: path + "." + k, Code: "invalid_format"}) + } else if k == "minimum" { + prop.Minimum = &n + } else { + prop.Maximum = &n + } + } + } + if v, ok := m["x-pii"]; ok { + _ = json.Unmarshal(v, &prop.PII) + } + if prop.Type == "array" { + if items, ok := m["items"]; ok { + it, errs := p.prop(items, path+".items", depth+1) + issues = append(issues, errs...) + prop.Items = it + } + } + if prop.Type == "object" { + if ps, ok := m["properties"]; ok { + var sub map[string]json.RawMessage + if json.Unmarshal(ps, &sub) == nil { + prop.Properties = map[string]*Prop{} + for _, k := range sortedKeys(sub) { + sp, errs := p.prop(sub[k], path+".properties."+k, depth+1) + issues = append(issues, errs...) + if sp != nil { + prop.Properties[k] = sp + } + } + } + } + } + if len(issues) == 0 && prop.Default != nil { + if is := prop.Validate(prop.Default, path+".default"); len(is) > 0 { + issues = append(issues, Issue{Path: path + ".default", Code: "invalid_format", Message: "default doesn't validate against its property"}) + } + } + return prop, issues +} + +// resolve follows an in-document JSON pointer ("#/$defs/x"). Remote +// references are rejected. +func (p parser) resolve(ref string) (json.RawMessage, error) { + if !strings.HasPrefix(ref, "#/") { + return nil, fmt.Errorf("only in-document $ref (#/...) is supported") + } + cur := p.root + for _, part := range strings.Split(ref[2:], "/") { + part = strings.ReplaceAll(strings.ReplaceAll(part, "~1", "/"), "~0", "~") + var m map[string]json.RawMessage + if json.Unmarshal(cur, &m) != nil { + return nil, fmt.Errorf("$ref %s doesn't resolve", ref) + } + next, ok := m[part] + if !ok { + return nil, fmt.Errorf("$ref %s doesn't resolve", ref) + } + cur = next + } + return cur, nil +} + +// Validate checks one value against the property. Path labels issues. +func (p *Prop) Validate(v json.RawMessage, path string) []Issue { + var x any + if err := json.Unmarshal(v, &x); err != nil { + return []Issue{{Path: path, Code: "invalid_format"}} + } + wrongType := []Issue{{Path: path, Code: "invalid_format", Message: "expected " + p.Type}} + switch p.Type { + case "string": + s, ok := x.(string) + if !ok { + return wrongType + } + if p.MaxLength != nil && utf8.RuneCountInString(s) > *p.MaxLength { + return []Issue{{Path: path, Code: "too_long", Max: f64(*p.MaxLength)}} + } + if p.Format == "date-time" { + if _, err := time.Parse(time.RFC3339, s); err != nil { + return []Issue{{Path: path, Code: "invalid_format", Message: "expected an RFC 3339 date-time"}} + } + } + case "boolean": + if _, ok := x.(bool); !ok { + return wrongType + } + case "integer", "number": + n, ok := x.(float64) + if !ok || (p.Type == "integer" && n != math.Trunc(n)) { + return wrongType + } + if (p.Minimum != nil && n < *p.Minimum) || (p.Maximum != nil && n > *p.Maximum) { + return []Issue{{Path: path, Code: "out_of_range", Min: p.Minimum, Max: p.Maximum}} + } + case "object": + m, ok := x.(map[string]any) + if !ok { + return wrongType + } + if p.Properties != nil { + var issues []Issue + for k, sv := range m { + sp, ok := p.Properties[k] + if !ok { + issues = append(issues, Issue{Path: path + "." + k, Code: "undeclared_key"}) + continue + } + b, _ := json.Marshal(sv) + issues = append(issues, sp.Validate(b, path+"."+k)...) + } + return issues + } + case "array": + arr, ok := x.([]any) + if !ok { + return wrongType + } + if p.Items != nil { + var issues []Issue + for i, it := range arr { + b, _ := json.Marshal(it) + issues = append(issues, p.Items.Validate(b, fmt.Sprintf("%s[%d]", path, i))...) + } + return issues + } + } + if len(p.Enum) > 0 { + canon, _ := json.Marshal(x) + for _, e := range p.Enum { + var ev any + _ = json.Unmarshal(e, &ev) + eb, _ := json.Marshal(ev) + if bytes.Equal(eb, canon) { + return nil + } + } + allowed := make([]string, len(p.Enum)) + for i, e := range p.Enum { + allowed[i] = strings.Trim(string(e), `"`) + } + return []Issue{{Path: path, Code: "enum_mismatch", Allowed: allowed}} + } + return nil +} + +// Scalar reports whether the property gets a typed projection (Database +// Schema → Typed fields): object and array never do. +func (p *Prop) Scalar() bool { + return p.Type == "string" || p.Type == "boolean" || p.Type == "integer" || p.Type == "number" +} + +// PIIKeys lists declared properties marked x-pii. +func (s *Schema) PIIKeys() []string { + var out []string + for _, k := range s.Order { + if s.Props[k].PII { + out = append(out, k) + } + } + return out +} + +func str(raw json.RawMessage) string { + var s string + _ = json.Unmarshal(raw, &s) + return s +} + +func f64(n int) *float64 { v := float64(n); return &v } + +func sortedKeys(m map[string]json.RawMessage) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j] < out[j-1]; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out +} diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go new file mode 100644 index 0000000..e083cda --- /dev/null +++ b/internal/schema/schema_test.go @@ -0,0 +1,69 @@ +package schema + +import ( + "encoding/json" + "testing" +) + +const timetrack = `{"type":"object","properties":{ + "default_billable":{"type":"boolean","default":true}, + "week_start":{"type":"string","enum":["sunday","monday"],"default":"sunday"}, + "invoice_footer":{"type":"string","maxLength":5,"x-pii":true}, + "rate":{"type":"number","minimum":0,"maximum":100}, + "due":{"type":"string","format":"date-time"}, + "tags":{"type":"array","items":{"$ref":"#/$defs/tag"}} +},"$defs":{"tag":{"type":"string","maxLength":3}}}` + +func TestParseValid(t *testing.T) { + s, issues, reserved := Parse(json.RawMessage(timetrack)) + if len(issues) != 0 || len(reserved) != 0 { + t.Fatalf("issues=%v reserved=%v", issues, reserved) + } + if len(s.Props) != 6 || !s.Props["invoice_footer"].PII { + t.Fatalf("props = %v", s.Order) + } + if got := s.PIIKeys(); len(got) != 1 || got[0] != "invoice_footer" { + t.Fatalf("pii = %v", got) + } +} + +func TestParseRules(t *testing.T) { + cases := map[string]string{ + "root type": `{"type":"array","properties":{}}`, + "bad name": `{"type":"object","properties":{"Bad-Name":{"type":"string"}}}`, + "bad type": `{"type":"object","properties":{"x":{"type":"date"}}}`, + "remote ref": `{"type":"object","properties":{"x":{"$ref":"https://example.com/s.json"}}}`, + "bad default": `{"type":"object","properties":{"x":{"type":"integer","default":"one"}}}`, + "bad format": `{"type":"object","properties":{"x":{"type":"string","format":"email"}}}`, + "dangling ref": `{"type":"object","properties":{"x":{"$ref":"#/$defs/missing"}}}`, + } + for name, raw := range cases { + if _, issues, _ := Parse(json.RawMessage(raw)); len(issues) == 0 { + t.Errorf("%s: expected an issue", name) + } + } + if _, _, reserved := Parse(json.RawMessage(`{"type":"object","properties":{"theme":{"type":"string"}}}`)); len(reserved) != 1 { + t.Error("reserved key not reported") + } +} + +func TestValidateValues(t *testing.T) { + s, _, _ := Parse(json.RawMessage(timetrack)) + ok := map[string]string{"default_billable": `false`, "week_start": `"monday"`, "rate": `50`, + "due": `"2026-10-05T12:00:00Z"`, "tags": `["a","bc"]`, "invoice_footer": `"hi"`} + for k, v := range ok { + if is := s.Props[k].Validate(json.RawMessage(v), k); len(is) != 0 { + t.Errorf("%s=%s: %v", k, v, is) + } + } + bad := map[string]string{"default_billable": `"yes"`, "week_start": `"friday"`, "rate": `101`, + "due": `"tomorrow"`, "tags": `["toolong"]`, "invoice_footer": `"too long"`} + codes := map[string]string{"default_billable": "invalid_format", "week_start": "enum_mismatch", "rate": "out_of_range", + "due": "invalid_format", "tags": "too_long", "invoice_footer": "too_long"} + for k, v := range bad { + is := s.Props[k].Validate(json.RawMessage(v), k) + if len(is) == 0 || is[0].Code != codes[k] { + t.Errorf("%s=%s: got %v, want %s", k, v, is, codes[k]) + } + } +} diff --git a/internal/testenv/testenv.go b/internal/testenv/testenv.go new file mode 100644 index 0000000..abc45e3 --- /dev/null +++ b/internal/testenv/testenv.go @@ -0,0 +1,516 @@ +// Package testenv runs the real API against a real, freshly migrated +// Postgres for integration and contract tests (NFR → Testing strategy: +// "not a mocked data layer"). Every response a test sees is validated +// against docs/openapi.yaml, so the spec can't silently drift from the +// implementation. +// +// Each test package gets its own database, created from TEST_DATABASE_URL +// (default: the docker-compose Postgres) and dropped afterwards, so +// packages run in parallel without sharing state. +package testenv + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/pb33f/libopenapi" + validator "github.com/pb33f/libopenapi-validator" + validatorerrors "github.com/pb33f/libopenapi-validator/errors" + + "github.com/Adron/substratalapps.com/internal/auth" + "github.com/Adron/substratalapps.com/internal/core" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/db/migrate" + "github.com/Adron/substratalapps.com/internal/db/pg" + "github.com/Adron/substratalapps.com/internal/email" + "github.com/Adron/substratalapps.com/internal/httpx" + "github.com/Adron/substratalapps.com/internal/webhooks" + "github.com/Adron/substratalapps.com/migrations" +) + +// AdminPassword is the bootstrap superadmin's password in tests. +const AdminPassword = "test-superadmin-passphrase-1" + +// Env is one running API. +type Env struct { + T testing.TB + Server *httptest.Server + Core *core.Server + DB db.DB + Mail *email.Capture + Deliverer *webhooks.Deliverer + Signer *auth.LocalSigner + Admin string // superadmin access token + AdminID string + Validate bool + + validator validator.Validator +} + +var ( + pkgOnce sync.Once + pkgDB string + pkgErr error +) + +func baseURL() string { + if u := os.Getenv("TEST_DATABASE_URL"); u != "" { + return u + } + return "postgres://substratal:substratal@localhost:55432/postgres?sslmode=disable" +} + +func withDB(u, name string) string { + p, _ := url.Parse(u) + p.Path = "/" + name + return p.String() +} + +// database creates and migrates this package's database, once. +func database(t testing.TB) string { + pkgOnce.Do(func() { + ctx := context.Background() + admin, err := pgx.Connect(ctx, baseURL()) + if err != nil { + pkgErr = fmt.Errorf("testenv: connect to %s (is `make up` running?): %w", baseURL(), err) + return + } + defer func() { _ = admin.Close(ctx) }() + b := make([]byte, 4) + _, _ = rand.Read(b) + name := "substratal_test_" + hex.EncodeToString(b) + if _, err := admin.Exec(ctx, "create database "+name); err != nil { + pkgErr = err + return + } + pkgDB = withDB(baseURL(), name) + d, err := pg.Open(ctx, pkgDB) + if err != nil { + pkgErr = err + return + } + defer d.Close() + all, err := migrate.Load(migrations.FS) + if err != nil { + pkgErr = err + return + } + if _, err := migrate.Up(ctx, d, all); err != nil { + pkgErr = err + } + }) + if pkgErr != nil { + t.Fatalf("%v", pkgErr) + } + return pkgDB +} + +// Drop removes this package's database; call it from TestMain. +func Drop() { + if pkgDB == "" { + return + } + ctx := context.Background() + admin, err := pgx.Connect(ctx, baseURL()) + if err != nil { + return + } + defer func() { _ = admin.Close(ctx) }() + p, _ := url.Parse(pkgDB) + _, _ = admin.Exec(ctx, "drop database if exists "+strings.TrimPrefix(p.Path, "/")+" with (force)") +} + +var ( + keyOnce sync.Once + testKey *rsa.PrivateKey + specMu sync.Mutex + specDoc libopenapi.Document +) + +func signer() *auth.LocalSigner { + keyOnce.Do(func() { testKey, _ = rsa.GenerateKey(rand.Reader, 2048) }) + return auth.NewLocalSignerFromKey(testKey) +} + +func specPath() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "..", "..", "docs", "openapi.yaml") +} + +func specValidator(t testing.TB) validator.Validator { + specMu.Lock() + defer specMu.Unlock() + if specDoc == nil { + b, err := os.ReadFile(specPath()) + if err != nil { + t.Fatalf("read spec: %v", err) + } + doc, err := libopenapi.NewDocument(b) + if err != nil { + t.Fatalf("parse spec: %v", err) + } + specDoc = doc + } + v, errs := validator.NewValidator(specDoc) + if len(errs) > 0 { + t.Fatalf("build validator: %v", errs) + } + return v +} + +// Options tune New. +type Options struct { + RateLimits bool // run with the production rate limits on +} + +// New starts an API for one test, with a fresh superadmin. +func New(t testing.TB) *Env { return NewWith(t, Options{}) } + +// NewWith is New with options. +func NewWith(t testing.TB, opts Options) *Env { + t.Helper() + ctx := context.Background() + d, err := pg.Open(ctx, database(t)) + if err != nil { + t.Fatalf("open db: %v", err) + } + enc, _ := auth.NewLocalEncrypter("") + mail := &email.Capture{} + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + if os.Getenv("TEST_LOG") != "" { + log = slog.New(slog.NewTextHandler(os.Stderr, nil)) + } + core.AllowPrivateWebhookTargets = true + deliverer := webhooks.New(d, enc, mail, log) + deliverer.AllowPrivate = true + s := core.New(core.Deps{DB: d, Signer: signer(), Encrypter: enc, Email: mail, + Cursors: httpx.Cursors{Key: []byte("test-cursor-key")}, Log: log, + Issuer: "https://api.substratalapps.com", PublicBaseURL: "http://localhost", AuthorizeURL: "https://auth.substratalapps.com/authorize", + DashboardURL: "https://dashboard.test", DisableRateLimits: !opts.RateLimits}) + srv := httptest.NewServer(s.Handler()) + e := &Env{T: t, Server: srv, Core: s, DB: d, Mail: mail, Deliverer: deliverer, Signer: signer(), Validate: true, + validator: specValidator(t)} + t.Cleanup(func() { srv.Close(); d.Close() }) + adminEmail := "admin+" + Unique() + "@example.com" + id, err := s.BootstrapSuperadmin(ctx, adminEmail, AdminPassword) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + e.AdminID = id + e.Admin = e.Login(adminEmail, AdminPassword).Token() + return e +} + +// Unique returns a short random suffix for emails, slugs, and names. +func Unique() string { + b := make([]byte, 5) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// Resp is one HTTP response. +type Resp struct { + T testing.TB + Status int + Body []byte + Header http.Header +} + +// JSON decodes the body as a generic map. +func (r *Resp) JSON() map[string]any { + var m map[string]any + if err := json.Unmarshal(r.Body, &m); err != nil { + r.T.Fatalf("body isn't a JSON object (%d): %s", r.Status, r.Body) + } + return m +} + +// Decode decodes the body into dst. +func (r *Resp) Decode(dst any) { + if err := json.Unmarshal(r.Body, dst); err != nil { + r.T.Fatalf("decode (%d): %v: %s", r.Status, err, r.Body) + } +} + +// Str returns a top-level string field. +func (r *Resp) Str(key string) string { + v, _ := r.JSON()[key].(string) + return v +} + +// Token returns access_token from an AuthSession. +func (r *Resp) Token() string { return r.Str("access_token") } + +// Code returns error.code, or "". +func (r *Resp) Code() string { + m := map[string]any{} + _ = json.Unmarshal(r.Body, &m) + if e, ok := m["error"].(map[string]any); ok { + c, _ := e["code"].(string) + return c + } + return "" +} + +// Expect fails the test unless the status matches. +func (r *Resp) Expect(status int) *Resp { + r.T.Helper() + if r.Status != status { + r.T.Fatalf("status = %d, want %d: %s", r.Status, status, r.Body) + } + return r +} + +// ExpectErr fails unless the status and error code match. +func (r *Resp) ExpectErr(status int, code string) *Resp { + r.T.Helper() + if r.Status != status || r.Code() != code { + r.T.Fatalf("got %d %q, want %d %q: %s", r.Status, r.Code(), status, code, r.Body) + } + return r +} + +// Req is a request builder. +type Req struct { + Method, Path, Token string + Body any + Header map[string]string +} + +// Do sends a request and validates the response against the spec. +func (e *Env) Do(rq Req) *Resp { + e.T.Helper() + var body io.Reader + var raw []byte + if rq.Body != nil { + switch b := rq.Body.(type) { + case string: + raw = []byte(b) + default: + raw, _ = json.Marshal(b) + } + body = bytes.NewReader(raw) + } + req, _ := http.NewRequest(rq.Method, e.Server.URL+rq.Path, body) + if rq.Body != nil { + req.Header.Set("Content-Type", "application/json") + } + if rq.Token != "" { + req.Header.Set("Authorization", "Bearer "+rq.Token) + } + for k, v := range rq.Header { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + e.T.Fatalf("%s %s: %v", rq.Method, rq.Path, err) + } + defer func() { _ = resp.Body.Close() }() + b, _ := io.ReadAll(resp.Body) + r := &Resp{T: e.T, Status: resp.StatusCode, Body: b, Header: resp.Header} + if e.Validate && strings.HasPrefix(rq.Path, "/v1/") { + e.contract(rq, raw, resp, b) + } + return r +} + +// contract validates the response body against docs/openapi.yaml. +func (e *Env) contract(rq Req, raw []byte, resp *http.Response, body []byte) { + e.T.Helper() + specReq, _ := http.NewRequest(rq.Method, "https://api.substratalapps.com"+rq.Path, bytes.NewReader(raw)) + specReq.Header.Set("Content-Type", "application/json") + if rq.Token != "" { + specReq.Header.Set("Authorization", "Bearer "+rq.Token) + } + for k, v := range rq.Header { + specReq.Header.Set(k, v) + } + copyResp := &http.Response{StatusCode: resp.StatusCode, Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(body)), Request: specReq} + if len(body) == 0 { + copyResp.Header.Del("Content-Type") + } + ok, errs := e.validator.ValidateHttpResponse(specReq, copyResp) + if ok { + return + } + // Cross-cutting errors (Conventions → Errors: unauthenticated, + // rate_limited, version_conflict, …) apply to every operation and are + // defined once there, so an operation needn't list each status. Accept + // an undeclared 4xx/5xx when its body is the standard error envelope. + if resp.StatusCode >= 400 && undeclaredStatus(errs) { + var env struct { + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(body, &env) == nil && env.Error != nil && env.Error.Code != "" && env.Error.Message != "" { + return + } + } + var msgs []string + for _, ve := range errs { + m := ve.Message + if ve.Reason != "" { + m += ": " + ve.Reason + } + for _, se := range ve.SchemaValidationErrors { + m += " [" + se.FieldPath + ": " + se.Reason + "]" + } + msgs = append(msgs, m) + } + e.T.Errorf("contract: %s %s → %d doesn't match docs/openapi.yaml:\n %s\n body: %s", + rq.Method, rq.Path, resp.StatusCode, strings.Join(msgs, "\n "), truncate(string(body), 600)) +} + +func undeclaredStatus(errs []*validatorErrors) bool { + for _, e := range errs { + if !strings.Contains(e.Message, "does not exist") && !strings.Contains(e.Reason, "has not been defined") { + return false + } + } + return len(errs) > 0 +} + +func truncate(s string, n int) string { + if len(s) > n { + return s[:n] + "…" + } + return s +} + +// Shorthands. +func (e *Env) Get(path, token string) *Resp { + return e.Do(Req{Method: "GET", Path: path, Token: token}) +} +func (e *Env) Post(path, token string, body any) *Resp { + return e.Do(Req{Method: "POST", Path: path, Token: token, Body: body}) +} +func (e *Env) Patch(path, token string, body any) *Resp { + return e.Do(Req{Method: "PATCH", Path: path, Token: token, Body: body}) +} +func (e *Env) Delete(path, token string) *Resp { + return e.Do(Req{Method: "DELETE", Path: path, Token: token}) +} + +// PostIdem sends a POST with an Idempotency-Key. +func (e *Env) PostIdem(path, token, key string, body any) *Resp { + return e.Do(Req{Method: "POST", Path: path, Token: token, Body: body, Header: map[string]string{"Idempotency-Key": key}}) +} + +// Login signs in and returns the AuthSession response. +func (e *Env) Login(emailAddr, password string) *Resp { + return e.Post("/v1/auth/login", "", map[string]any{"email": emailAddr, "password": password}).Expect(200) +} + +// User is a signed-up test user. +type User struct { + ID, Email, Password, Token, Refresh string +} + +// Signup creates an active user through the public API and signs them in. +func (e *Env) Signup() User { + e.T.Helper() + u := User{Email: "user+" + Unique() + "@example.com", Password: "a long enough test passphrase"} + r := e.Post("/v1/auth/signup", "", map[string]any{"email": u.Email, "password": u.Password}).Expect(201) + u.ID, u.Token, u.Refresh = r.Str("user_id"), r.Token(), r.Str("refresh_token") + return u +} + +// App creates an Application owned by ownerID (or the system account). +func (e *Env) App(ownerID string, extra map[string]any) string { + e.T.Helper() + slug := "app-" + Unique() + body := map[string]any{"slug": slug, "name": "Test " + slug, "launch_url": "https://" + slug + ".example.com/launch", + "owner_user_id": ownerID, "available_app_roles": []string{"admin", "member"}, "default_app_role": "member", + "permissions": []map[string]any{{"key": "app." + slug + ".view"}, {"key": "app." + slug + ".export"}}} + for k, v := range extra { + body[k] = v + } + r := e.Post("/v1/applications", e.Admin, body).Expect(201) + return r.Str("id") +} + +// Grant gives userID a personal admin_grant Entitlement to appID. +func (e *Env) Grant(token, userID, appID string) string { + e.T.Helper() + return e.PostIdem("/v1/users/"+userID+"/entitlements", token, Unique(), + map[string]any{"application_id": appID, "source": "admin_grant"}).Expect(201).Str("id") +} + +// Deliver runs the webhook pipeline to completion. +func (e *Env) Deliver() { + e.T.Helper() + if err := e.Deliverer.Run(context.Background()); err != nil { + e.T.Fatalf("deliver: %v", err) + } +} + +// Events lists outbox events of a type, newest first. +func (e *Env) Events(eventType string) []map[string]any { + e.T.Helper() + var out []map[string]any + err := e.DB.Tx(context.Background(), db.Settings{AllModes: true}, func(q db.Querier) error { + rows, err := db.All[map[string]any](context.Background(), q, `select jsonb_build_object('id', id, 'type', type, + 'application_id', application_id, 'payload', payload, 'test_mode', test_mode) from webhook_events + where type = :t order by created_at desc, id desc`, db.Args{"t": eventType}) + out = rows + return err + }) + if err != nil { + e.T.Fatalf("events: %v", err) + } + return out +} + +// Token returns the most recent emailed token of a prefix sent to addr. +func (e *Env) Token(addr, kind string) string { + e.T.Helper() + m, ok := e.Mail.Last(addr, kind) + if !ok { + e.T.Fatalf("no %s email to %s", kind, addr) + } + i := strings.Index(m.Text, "token=") + if i < 0 { + e.T.Fatalf("no token in email: %s", m.Text) + } + tok := m.Text[i+6:] + if j := strings.IndexAny(tok, "\n "); j >= 0 { + tok = tok[:j] + } + v, _ := url.QueryUnescape(tok) + return v +} + +// Wait polls cond for up to d. +func Wait(d time.Duration, cond func() bool) bool { + end := time.Now().Add(d) + for time.Now().Before(end) { + if cond() { + return true + } + time.Sleep(20 * time.Millisecond) + } + return cond() +} + +type validatorErrors = validatorerrors.ValidationError diff --git a/internal/webhooks/notifier.go b/internal/webhooks/notifier.go new file mode 100644 index 0000000..e00feb8 --- /dev/null +++ b/internal/webhooks/notifier.go @@ -0,0 +1,61 @@ +package webhooks + +import ( + "context" + "log/slog" + "time" + + "github.com/aws/aws-sdk-go-v2/service/sqs" +) + +// SQSNotifier nudges the webhook worker Lambda through SQS after a commit. +// The message carries only the event (or delivery) id; the worker reads +// everything else from the outbox, so a lost message only delays delivery +// until the scheduled sweep. +type SQSNotifier struct { + Client *sqs.Client + QueueURL string + Log *slog.Logger +} + +func (n SQSNotifier) EventCommitted(ctx context.Context, id string) { + _, err := n.Client.SendMessage(ctx, &sqs.SendMessageInput{QueueUrl: &n.QueueURL, MessageBody: &id}) + if err != nil { + n.Log.Warn("webhook nudge failed; the scheduled sweep will deliver it", "id", id, "err", err) + } +} + +// LocalNotifier runs the Deliverer in-process: every nudge triggers a +// pass, and a ticker sweeps for retries that come due. The devserver uses +// it so webhooks work on a laptop with no queue at all. +type LocalNotifier struct { + kick chan struct{} +} + +// NewLocal starts the in-process loop and returns its notifier. +func NewLocal(ctx context.Context, d *Deliverer, every time.Duration) *LocalNotifier { + n := &LocalNotifier{kick: make(chan struct{}, 1)} + go func() { + t := time.NewTicker(every) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-n.kick: + case <-t.C: + } + if err := d.Run(ctx); err != nil && ctx.Err() == nil { + d.Log.Error("webhook worker pass failed", "err", err) + } + } + }() + return n +} + +func (n *LocalNotifier) EventCommitted(context.Context, string) { + select { + case n.kick <- struct{}{}: + default: + } +} diff --git a/internal/webhooks/sign_test.go b/internal/webhooks/sign_test.go new file mode 100644 index 0000000..10cbca4 --- /dev/null +++ b/internal/webhooks/sign_test.go @@ -0,0 +1,24 @@ +package webhooks + +import ( + "strings" + "testing" + "time" +) + +func TestSignTwoSecrets(t *testing.T) { + h := Sign([]byte(`{"id":"wev_1"}`), time.Unix(1730649761, 0), "whsec_new", "whsec_old") + parts := strings.Split(h, ",") + if parts[0] != "t=1730649761" || len(parts) != 3 || !strings.HasPrefix(parts[1], "v1=") { + t.Fatalf("header = %s", h) + } + if parts[1] == parts[2] { + t.Fatal("each secret signs separately") + } +} + +func TestRetrySchedule(t *testing.T) { + if len(RetrySchedule) != maxAttempts-1 { + t.Fatalf("6 attempts means 5 retries, got %d", len(RetrySchedule)) + } +} diff --git a/internal/webhooks/webhooks.go b/internal/webhooks/webhooks.go new file mode 100644 index 0000000..5c74ccc --- /dev/null +++ b/internal/webhooks/webhooks.go @@ -0,0 +1,433 @@ +// Package webhooks delivers outbound webhook events (API Reference → +// Webhooks → Delivery): it fans committed outbox events out to matching +// subscriptions, signs and POSTs each delivery, and runs the retry and +// subscription-health rules. +// +// It's driven the same way everywhere: the API nudges it after a commit +// (SQS in AWS, an in-process channel locally), and a scheduled sweep picks +// up anything a nudge missed, so the outbox is the only source of truth. +package webhooks + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/Adron/substratalapps.com/internal/auth" + "github.com/Adron/substratalapps.com/internal/core" + "github.com/Adron/substratalapps.com/internal/db" + "github.com/Adron/substratalapps.com/internal/email" + "github.com/Adron/substratalapps.com/internal/ids" +) + +// RetrySchedule is the delay before attempts 2 through 6. +var RetrySchedule = []time.Duration{time.Minute, 5 * time.Minute, 30 * time.Minute, 2 * time.Hour, 12 * time.Hour} + +const ( + maxAttempts = 6 + unhealthyAfter = 3 + disableAfterDays = 5 + leaseFor = 2 * time.Minute + timeout = 5 * time.Second +) + +// Deliverer runs dispatch and delivery. +type Deliverer struct { + DB db.DB + Encrypter auth.Encrypter + Email email.Sender + Log *slog.Logger + Client *http.Client + Now func() time.Time + // AllowPrivate skips the send-time public-address check (local only). + AllowPrivate bool +} + +// New returns a Deliverer with a client that never follows redirects. +func New(d db.DB, enc auth.Encrypter, mail email.Sender, log *slog.Logger) *Deliverer { + return &Deliverer{DB: d, Encrypter: enc, Email: mail, Log: log, Now: time.Now, + Client: &http.Client{Timeout: timeout, CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }}} +} + +type event struct { + ID string `json:"id"` + Type string `json:"type"` + ApplicationID *string `json:"application_id"` + Payload json.RawMessage `json:"payload"` + TestMode bool `json:"test_mode"` + CreatedAt time.Time `json:"created_at"` +} + +// Dispatch fans every undispatched outbox event out to its subscriptions, +// creating attempt-1 deliveries. It returns how many events it handled. +func (d *Deliverer) Dispatch(ctx context.Context, limit int) (int, error) { + handled := 0 + err := d.DB.Tx(ctx, db.Settings{AllModes: true}, func(q db.Querier) error { + evs, err := db.All[event](ctx, q, `select to_jsonb(e) from webhook_events e + where dispatched_at is null order by created_at limit :lim for update skip locked`, db.Args{"lim": int64(limit)}) + if err != nil { + return err + } + for _, ev := range evs { + subs, err := matchingSubscriptions(ctx, q, ev) + if err != nil { + return err + } + for _, sid := range subs { + if _, err := q.Exec(ctx, `insert into webhook_deliveries (id, subscription_id, event_id, attempt, status, next_retry_at, test_mode) + values (:id, :s, :e, 1, 'pending', now(), :tm)`, + db.Args{"id": ids.New(ids.Delivery), "s": sid, "e": ev.ID, "tm": ev.TestMode}); err != nil { + return err + } + } + if _, err := q.Exec(ctx, `update webhook_events set dispatched_at = now() where id = :id`, db.Args{"id": ev.ID}); err != nil { + return err + } + handled++ + } + return nil + }) + return handled, err +} + +// matchingSubscriptions applies "Who owns a subscription, and what it +// receives": platform subscriptions get every event; an app-scoped one +// gets events for its Application, and user.* / organization.member_* +// events only when the user has an access path to that Application. Test +// and live never cross. +func matchingSubscriptions(ctx context.Context, q db.Querier, ev event) ([]string, error) { + var data struct { + UserID string `json:"user_id"` + } + _ = json.Unmarshal(ev.Payload, &data) + userScoped := ev.ApplicationID == nil && data.UserID != "" && + (strings.HasPrefix(ev.Type, "user.") || strings.HasPrefix(ev.Type, "organization.member_")) + return db.All[string](ctx, q, `select to_jsonb(w.id) from webhook_subscriptions w + where w.test_mode = :tm and w.status <> 'disabled' + and (w.events @> array[:type]::text[] or w.events = '{*}') + and (w.scope = 'platform' + or (:app::text is not null and w.scope = :app) + or (:userscoped and ( + exists (select 1 from entitlements e where e.user_id = :u and e.application_id = w.scope) + or exists (select 1 from entitlements e join organization_memberships m on m.organization_id = e.organization_id + where m.user_id = :u and e.application_id = w.scope)))) + order by w.id`, + db.Args{"tm": ev.TestMode, "type": ev.Type, "app": ev.ApplicationID, "userscoped": userScoped, "u": data.UserID}) +} + +type due struct { + ID string `json:"id"` + SubscriptionID string `json:"subscription_id"` + EventID string `json:"event_id"` + Attempt int `json:"attempt"` + URL string `json:"url"` + APIVersion string `json:"api_version"` + Secret string `json:"secret_ciphertext"` + PrevSecret *string `json:"previous_secret_ciphertext"` + PrevExpires *time.Time `json:"previous_secret_expires_at"` + SubStatus string `json:"sub_status"` + Event event `json:"event"` +} + +// DeliverDue sends every pending delivery whose time has come. Each is +// claimed with a short lease in its own transaction, so no transaction is +// held open across the HTTP call. It returns how many it attempted. +func (d *Deliverer) DeliverDue(ctx context.Context, limit int) (int, error) { + var batch []due + err := d.DB.Tx(ctx, db.Settings{AllModes: true}, func(q db.Querier) error { + var err error + batch, err = db.All[due](ctx, q, `select jsonb_build_object('id', d.id, 'subscription_id', d.subscription_id, + 'event_id', d.event_id, 'attempt', d.attempt, 'url', w.url, 'api_version', w.api_version, + 'secret_ciphertext', w.secret_ciphertext, 'previous_secret_ciphertext', w.previous_secret_ciphertext, + 'previous_secret_expires_at', w.previous_secret_expires_at, 'sub_status', w.status, 'event', to_jsonb(e)) + from webhook_deliveries d + join webhook_subscriptions w on w.id = d.subscription_id + join webhook_events e on e.id = d.event_id + where d.status = 'pending' and d.next_retry_at <= now() + order by d.next_retry_at limit :lim for update of d skip locked`, db.Args{"lim": int64(limit)}) + if err != nil { + return err + } + for _, x := range batch { + if _, err := q.Exec(ctx, `update webhook_deliveries set next_retry_at = now() + :lease::interval where id = :id`, + db.Args{"lease": fmt.Sprintf("%d seconds", int(leaseFor.Seconds())), "id": x.ID}); err != nil { + return err + } + } + return nil + }) + if err != nil { + return 0, err + } + for _, x := range batch { + d.deliver(ctx, x) + } + return len(batch), nil +} + +// Envelope builds the body POSTed to a subscriber for its api_version. +// 2026-10-05 is the only published version, so there's one shape. +func Envelope(ev event, apiVersion string) []byte { + var data any + _ = json.Unmarshal(ev.Payload, &data) + b, _ := json.Marshal(map[string]any{ + "id": ev.ID, "type": ev.Type, "api_version": apiVersion, + "created_at": ev.CreatedAt.UTC().Format(time.RFC3339), "application_id": ev.ApplicationID, + "test_mode": ev.TestMode, "data": data, + }) + return b +} + +// Sign returns the Substratal-Signature header value: t=,v1= +// for each secret (two during a rotation overlap). +func Sign(body []byte, t time.Time, secrets ...string) string { + ts := strconv.FormatInt(t.Unix(), 10) + parts := []string{"t=" + ts} + for _, s := range secrets { + mac := hmac.New(sha256.New, []byte(s)) + mac.Write([]byte(ts + ".")) + mac.Write(body) + parts = append(parts, "v1="+hex.EncodeToString(mac.Sum(nil))) + } + return strings.Join(parts, ",") +} + +func (d *Deliverer) deliver(ctx context.Context, x due) { + now := d.Now() + isTest := x.Event.Type == "webhook.test" + if x.SubStatus == "disabled" && !isTest { + d.finish(ctx, x, "failed", nil, 0, strp("subscription disabled"), false) + return + } + secrets, err := d.secrets(ctx, x, now) + if err != nil { + d.Log.Error("webhook secret decrypt failed", "subscription", x.SubscriptionID, "err", err) + d.finish(ctx, x, "failed", nil, 0, strp("internal signing error"), !isTest) + return + } + body := Envelope(x.Event, x.APIVersion) + start := time.Now() + status, errText := d.post(ctx, x, body, secrets, now) + dur := int(time.Since(start).Milliseconds()) + switch { + case status >= 200 && status < 300: + d.finish(ctx, x, "succeeded", &status, dur, nil, false) + case status == http.StatusGone: + d.finish(ctx, x, "failed", &status, dur, strp("410 Gone: subscription disabled"), false) + d.disable(ctx, x.SubscriptionID, "410") + default: + d.finish(ctx, x, "failed", statusOrNil(status), dur, &errText, !isTest) + } +} + +func statusOrNil(s int) *int { + if s == 0 { + return nil + } + return &s +} + +func strp(s string) *string { return &s } + +func (d *Deliverer) secrets(ctx context.Context, x due, now time.Time) ([]string, error) { + cur, err := d.Encrypter.Decrypt(ctx, x.Secret) + if err != nil { + return nil, err + } + out := []string{cur} + if x.PrevSecret != nil && x.PrevExpires != nil && x.PrevExpires.After(now) { + if prev, err := d.Encrypter.Decrypt(ctx, *x.PrevSecret); err == nil { + out = append(out, prev) + } + } + return out, nil +} + +func (d *Deliverer) post(ctx context.Context, x due, body []byte, secrets []string, now time.Time) (int, string) { + u, err := url.Parse(x.URL) + if err != nil { + return 0, "invalid url" + } + if !d.AllowPrivate { + if u.Scheme != "https" { + return 0, "url is not https" + } + if err := core.CheckPublicHost(ctx, u.Hostname()); err != nil { + return 0, "url resolves to a non-public address" + } + } + rctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + req, err := http.NewRequestWithContext(rctx, http.MethodPost, x.URL, bytes.NewReader(body)) + if err != nil { + return 0, err.Error() + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Substratal-Webhooks/1.0") + req.Header.Set("Substratal-Event-Id", x.Event.ID) + req.Header.Set("Substratal-Event-Type", x.Event.Type) + req.Header.Set("Substratal-Delivery-Id", x.ID) + req.Header.Set("Substratal-Delivery-Attempt", strconv.Itoa(x.Attempt)) + req.Header.Set("Substratal-Signature", Sign(body, now, secrets...)) + resp, err := d.Client.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "Timeout") { + return 0, "timeout" + } + return 0, truncate(err.Error(), 1024) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) + return resp.StatusCode, "" + } + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return resp.StatusCode, truncate(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, b), 1024) +} + +func truncate(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} + +// finish records an attempt's outcome, schedules the next attempt on a +// retryable failure, and updates subscription health. countsTowardHealth +// is false for test events and for 410 handling. +func (d *Deliverer) finish(ctx context.Context, x due, status string, code *int, dur int, errText *string, countsTowardHealth bool) { + err := d.DB.Tx(ctx, db.Settings{AllModes: true}, func(q db.Querier) error { + var durArg any + if dur > 0 { + durArg = int64(dur) + } + var codeArg any + if code != nil { + codeArg = int64(*code) + } + if _, err := q.Exec(ctx, `update webhook_deliveries set status = :st, response_status = :code, duration_ms = :dur, + error = :err, next_retry_at = null where id = :id`, + db.Args{"st": status, "code": codeArg, "dur": durArg, "err": errText, "id": x.ID}); err != nil { + return err + } + if _, err := q.Exec(ctx, `update webhook_subscriptions set last_delivery_at = now() where id = :s`, + db.Args{"s": x.SubscriptionID}); err != nil { + return err + } + if status == "succeeded" { + if x.Event.Type != "webhook.test" { + _, err := q.Exec(ctx, `update webhook_subscriptions set consecutive_failures = 0, unhealthy_since = null, + status = case when status = 'unhealthy' then 'healthy' else status end where id = :s`, db.Args{"s": x.SubscriptionID}) + return err + } + return nil + } + if !countsTowardHealth { + return nil + } + if x.Attempt < maxAttempts { + next := d.Now().Add(RetrySchedule[x.Attempt-1]) + _, err := q.Exec(ctx, `insert into webhook_deliveries (id, subscription_id, event_id, attempt, status, next_retry_at, test_mode) + values (:id, :s, :e, :a, 'pending', :at::timestamptz, (select test_mode from webhook_events where id = :e))`, + db.Args{"id": ids.New(ids.Delivery), "s": x.SubscriptionID, "e": x.EventID, "a": int64(x.Attempt + 1), + "at": db.Time(next)}) + if err == nil { + _, err = q.Exec(ctx, `update webhook_deliveries set next_retry_at = :at::timestamptz where id = :id`, + db.Args{"at": db.Time(next), "id": x.ID}) + } + return err + } + // Every retry exhausted: this delivery counts toward unhealthy. + _, err := q.Exec(ctx, `update webhook_subscriptions set consecutive_failures = consecutive_failures + 1, + status = case when consecutive_failures + 1 >= :n and status = 'healthy' then 'unhealthy' else status end, + unhealthy_since = case when consecutive_failures + 1 >= :n and unhealthy_since is null then now() else unhealthy_since end + where id = :s`, db.Args{"n": int64(unhealthyAfter), "s": x.SubscriptionID}) + return err + }) + if err != nil { + d.Log.Error("record webhook delivery", "delivery", x.ID, "err", err) + } +} + +func (d *Deliverer) disable(ctx context.Context, subID, why string) { + err := d.DB.Tx(ctx, db.Settings{AllModes: true}, func(q db.Querier) error { + _, err := q.Exec(ctx, `update webhook_subscriptions set status = 'disabled' where id = :s`, db.Args{"s": subID}) + return err + }) + if err != nil { + d.Log.Error("disable webhook subscription", "subscription", subID, "err", err) + return + } + d.Log.Warn("webhook subscription disabled", "subscription", subID, "reason", why) +} + +// DisableLongUnhealthy disables subscriptions unhealthy for 5 consecutive +// days and emails the Application's owner. Run by the scheduled jobs. +func (d *Deliverer) DisableLongUnhealthy(ctx context.Context) (int, error) { + type row struct { + ID string `json:"id"` + Scope string `json:"scope"` + URL string `json:"url"` + Owners []string `json:"owners"` + } + var rows []row + err := d.DB.Tx(ctx, db.Settings{AllModes: true}, func(q db.Querier) error { + var err error + rows, err = db.All[row](ctx, q, `update webhook_subscriptions w set status = 'disabled' + where w.status = 'unhealthy' and w.unhealthy_since < now() - interval '5 days' + returning jsonb_build_object('id', w.id, 'scope', w.scope, 'url', w.url, + 'owners', coalesce((select jsonb_agg(u.email) from applications a + join users u on u.id = a.owner_user_id where a.id = w.scope), '[]'::jsonb) + || coalesce((select jsonb_agg(u.email) from applications a + join organization_memberships m on m.organization_id = a.owner_organization_id + and m.role = 'org_admin' and m.status = 'active' + join users u on u.id = m.user_id where a.id = w.scope), '[]'::jsonb))`, nil) + return err + }) + if err != nil { + return 0, err + } + for _, r := range rows { + for _, to := range r.Owners { + if err := d.Email.Send(ctx, email.Message{To: to, Kind: "webhook_disabled", + Subject: "A Substratal webhook subscription was disabled", + Text: fmt.Sprintf("Webhook %s (%s) for %s was disabled after %d days of failed deliveries. "+ + "Fix the endpoint, then PATCH its status back to healthy.", r.ID, r.URL, r.Scope, disableAfterDays)}); err != nil { + d.Log.Error("email webhook owner", "subscription", r.ID, "err", err) + } + } + } + return len(rows), nil +} + +// Run processes until nothing is left to do: dispatch, then deliver. +func (d *Deliverer) Run(ctx context.Context) error { + for i := 0; i < 50; i++ { + n, err := d.Dispatch(ctx, 100) + if err != nil { + return err + } + m, err := d.DeliverDue(ctx, 50) + if err != nil { + return err + } + if n == 0 && m == 0 { + return nil + } + } + return nil +} diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..13957a1 --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,650 @@ +-- 0001_init: the full schema from docs/domain-model/database-schema.md. +-- +-- Forward-only (README → Migrations: expand, then contract). Applied by +-- cmd/migrate, one statement at a time, in a transaction per file. + +create extension if not exists citext; +create schema if not exists substratal_util; + +-- ── Helpers ───────────────────────────────────────────────────────────── + +-- updated_at is maintained by trigger, not application code, so it's +-- correct even for a direct UPDATE from a migration or support script. +create function substratal_util.touch_updated_at() returns trigger language plpgsql as $$ +begin + new.updated_at := now(); + return new; +end $$; + +-- version backs ETag / If-Match. Bumped only when the row actually changes. +create function substratal_util.bump_version() returns trigger language plpgsql as $$ +begin + if row(new.*) is distinct from row(old.*) then + new.version := old.version + 1; + end if; + return new; +end $$; + +-- Test/live isolation (Conventions → Authentication). Every request's +-- transaction sets app.current_test_mode to 'true' or 'false'; scheduled +-- jobs that span both modes set 'any'. Unset fails closed to live-only. +create function substratal_util.mode_visible(row_mode boolean) returns boolean +language sql stable as $$ + select case coalesce(nullif(current_setting('app.current_test_mode', true), ''), 'false') + when 'any' then true + when 'true' then row_mode + else not row_mode + end +$$; + +-- Null-on-failure casts for typed per-Application settings views +-- (Database Schema → Typed fields). A stale value never breaks a read. +create function substratal_util.try_bool(j jsonb) returns boolean immutable language sql as $$ + select case when jsonb_typeof(j) = 'boolean' then (j #>> '{}')::boolean end $$; +create function substratal_util.try_num(j jsonb) returns numeric immutable language sql as $$ + select case when jsonb_typeof(j) = 'number' then (j #>> '{}')::numeric end $$; +create function substratal_util.try_int(j jsonb) returns bigint immutable language sql as $$ + select case when jsonb_typeof(j) = 'number' and (j #>> '{}') ~ '^-?[0-9]+$' then (j #>> '{}')::bigint end $$; +create function substratal_util.try_text(j jsonb) returns text immutable language sql as $$ + select case when jsonb_typeof(j) = 'string' then j #>> '{}' end $$; +create function substratal_util.try_timestamptz(j jsonb) returns timestamptz immutable language plpgsql as $$ +begin + if jsonb_typeof(j) <> 'string' then return null; end if; + return (j #>> '{}')::timestamptz; +exception when others then + return null; +end $$; + +-- ── Users & identity ──────────────────────────────────────────────────── + +create table users ( + id text primary key, + email citext not null, + email_verified boolean not null default false, + status text not null check (status in ('active','invited','suspended','deleted')), + pending_email citext, + signup_application_id text, -- FK added after applications exists + test_mode boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + last_login_at timestamptz, + version integer not null default 1, + deleted_at timestamptz +); +create unique index users_email_mode_key on users (email, test_mode) where deleted_at is null; +create index users_email_prefix on users (lower(email::text) text_pattern_ops); +create index users_status on users (status); + +create table organizations ( + id text primary key, + name text not null, + status text not null default 'active' check (status in ('active','suspended')), + created_by text not null references users(id), + test_mode boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + version integer not null default 1 +); + +create table sso_connections ( + id text primary key, + organization_id text not null references organizations(id), + provider text not null, + domain text, + status text not null default 'active' check (status in ('active','inactive')), + created_at timestamptz not null default now() +); +create unique index sso_connections_domain_key on sso_connections (domain) + where domain is not null and status = 'active'; + +create table user_identities ( + id text primary key, + user_id text not null references users(id), + method text not null check (method in ('password','sso')), + password_hash text, + sso_connection_id text references sso_connections(id), + external_subject_id text, + mfa_enabled boolean not null default false, + mfa_secret text, + mfa_pending_secret text, + mfa_pending_expires_at timestamptz, + mfa_last_step bigint, -- last accepted TOTP time step: a code can't be reused in its window + failed_login_count int not null default 0, + failed_login_window_start timestamptz, + locked_until timestamptz, + last_used_at timestamptz, + created_at timestamptz not null default now(), + -- Database Schema requires password_hash on a password identity; the + -- erasure cascade (NFR → Hard-delete cascade, step 1) then deletes it + -- while keeping the row, so a password row may lose its hash only once + -- its User is erased (enforced by the cascade, which nulls it last). + constraint user_identities_method_shape check ( + (method = 'password' and sso_connection_id is null) or + (method = 'sso' and sso_connection_id is not null and password_hash is null) + ) +); +create index user_identities_user on user_identities (user_id); + +create table sessions ( + id text primary key, + user_id text not null references users(id), + amr text[] not null, + ip_address inet, + user_agent text, + test_mode boolean not null default false, + created_at timestamptz not null default now(), + last_seen_at timestamptz not null default now(), + idle_expires_at timestamptz not null, + absolute_expires_at timestamptz not null, + revoked_at timestamptz, + revoked_reason text +); +create index sessions_user_active on sessions (user_id) where revoked_at is null; + +create table refresh_tokens ( + token_hash text primary key, + session_id text not null references sessions(id) on delete cascade, + application_id text, + rotated_at timestamptz, + created_at timestamptz not null default now() +); +create index refresh_tokens_session on refresh_tokens (session_id); + +create table auth_tokens ( + token_hash text primary key, + kind text not null check (kind in ('email_verify','password_reset','invitation','mfa_challenge','authorization_code')), + user_id text not null references users(id), + payload jsonb not null default '{}', + attempts int not null default 0, + expires_at timestamptz not null, + consumed_at timestamptz, + created_at timestamptz not null default now() +); +create index auth_tokens_user_kind_open on auth_tokens (user_id, kind) where consumed_at is null; + +create table mfa_recovery_codes ( + user_identity_id text not null references user_identities(id), + code_hash text not null, + used_at timestamptz, + primary key (user_identity_id, code_hash) +); + +create table organization_memberships ( + user_id text not null references users(id), + organization_id text not null references organizations(id), + role text not null check (role in ('org_admin','member')), + status text not null default 'active' check (status in ('pending','active')), + invited_by text references users(id), + invited_at timestamptz not null default now(), + joined_at timestamptz, + test_mode boolean not null default false, + primary key (user_id, organization_id), + check ((status = 'active') = (joined_at is not null)) +); +create index organization_memberships_org on organization_memberships (organization_id); + +-- ── Tenancy & catalog ────────────────────────────────────────────────── + +create table tenants ( + id text primary key, + owner_type text not null check (owner_type in ('user','organization')), + owner_user_id text references users(id), + owner_organization_id text references organizations(id), + tier text not null default 'shared' check (tier in ('shared','isolated','dedicated_region')), + plan text not null default 'starter' check (plan in ('starter','team','enterprise')), + region text not null default 'us-east-1' + check (region in ('us-east-1','us-west-2','ca-central-1','eu-west-1','eu-central-1','ap-southeast-2')), + stripe_customer_id text unique, + stripe_subscription_id text unique, + subscription_status text not null default 'none' + check (subscription_status in ('none','active','trialing','past_due','canceled','unpaid','incomplete','incomplete_expired','paused')), + current_period_start timestamptz, + current_period_end timestamptz, + cancel_at_period_end boolean not null default false, + restricted boolean not null default false, + seat_count_synced integer, + seat_count_synced_at timestamptz, + status text not null default 'active' check (status in ('active','migrating','suspended')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + check ((owner_type = 'user') = (owner_user_id is not null)), + check (owner_user_id is null or owner_organization_id is null), + check (owner_user_id is not null or owner_organization_id is not null), + check (tier = 'dedicated_region' or region = 'us-east-1'), + check (plan = 'enterprise' or tier = 'shared'), + check (plan <> 'starter' or owner_type = 'user') +); +create unique index tenants_owner_user_key on tenants (owner_user_id) where owner_user_id is not null; +create unique index tenants_owner_org_key on tenants (owner_organization_id) where owner_organization_id is not null; + +create table applications ( + id text primary key, + slug text not null unique, + name text not null, + description text, + icon_url text, + launch_url text not null, + support_url text, + email_from_name text, + redirect_uris text[] not null default '{}', + settings_schema jsonb not null default '{"type":"object","properties":{}}', + permissions jsonb not null default '[]', + available_app_roles text[] not null default '{}', + default_app_role text, + visibility text not null check (visibility in ('public','invite_only','internal')), + owner_user_id text references users(id), + owner_organization_id text references organizations(id), + review_status text not null default 'approved' + check (review_status in ('approved','pending_review','rejected','suspended')), + review_notes text, + tenant_id text not null references tenants(id), + test_mode boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + version integer not null default 1, + check (id = 'app_' || slug), + check (slug ~ '^[a-z][a-z0-9-]{1,63}$' and slug <> 'platform'), + check (cardinality(redirect_uris) <= 10), + check (default_app_role is null or default_app_role = any(available_app_roles)), + check (review_notes is not null or review_status not in ('rejected','suspended')), + check (owner_user_id is null or owner_organization_id is null), + check (owner_user_id is not null or owner_organization_id is not null) +); +create index applications_owner_user on applications (owner_user_id); +create index applications_owner_org on applications (owner_organization_id); +create index applications_catalog on applications (visibility, review_status) where review_status = 'approved'; +create index applications_tenant on applications (tenant_id); + +alter table users add constraint users_signup_application_fk + foreign key (signup_application_id) references applications(id); +alter table refresh_tokens add constraint refresh_tokens_application_fk + foreign key (application_id) references applications(id); + +-- ── Entitlements ─────────────────────────────────────────────────────── + +create table entitlements ( + id text primary key, + user_id text references users(id), + organization_id text references organizations(id), + application_id text not null references applications(id), + tenant_id text not null references tenants(id), + status text not null check (status in ('active','disabled','expired','revoked')), + source text not null check (source in ('purchase','trial','admin_grant','org_seat')), + order_id text check (char_length(order_id) <= 255), + starts_at timestamptz not null default now(), + ends_at timestamptz, + disabled_reason text, + member_scope text check (member_scope in ('all_members','allowlist','denylist')), + member_overrides text[] check (cardinality(member_overrides) <= 1000), + start_notified_at timestamptz, -- set once access.granted has fired for a future starts_at + test_mode boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + version integer not null default 1, + check ((user_id is null) <> (organization_id is null)), + check ((source = 'org_seat') = (organization_id is not null)), + check (source <> 'trial' or ends_at is not null), + check (status not in ('disabled','revoked') or disabled_reason is not null) +); +create unique index entitlements_user_app_key on entitlements (user_id, application_id, test_mode) + where user_id is not null and status in ('active','disabled'); +create unique index entitlements_org_app_key on entitlements (organization_id, application_id, test_mode) + where organization_id is not null and status in ('active','disabled'); +create index entitlements_order on entitlements (application_id, order_id) where order_id is not null; +create index entitlements_expiry on entitlements (status, ends_at) + where status in ('active','disabled') and ends_at is not null; +create index entitlements_starts on entitlements (starts_at) where status = 'active'; +create index entitlements_app_status on entitlements (application_id, status); +create index entitlements_org on entitlements (organization_id); +create index entitlements_user on entitlements (user_id); + +-- ── Roles ────────────────────────────────────────────────────────────── + +create table roles ( + id text primary key, + name text not null check (name ~ '^[a-z][a-z0-9_]{1,40}$'), + scope text not null, + description text, + permissions text[] not null default '{}', + seed boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + version integer not null default 1, + test_mode boolean not null default false, + unique (scope, name, test_mode) +); + +create table user_role_assignments ( + user_id text not null references users(id), + role_id text not null references roles(id), + application_id text, + assigned_at timestamptz not null default now(), + assigned_by_type text not null check (assigned_by_type in ('user','api_key','system')), + assigned_by_id text not null, + test_mode boolean not null default false, + primary key (user_id, role_id) +); +create index user_role_assignments_user_app on user_role_assignments (user_id, application_id); +create index user_role_assignments_role on user_role_assignments (role_id); + +-- ── Profiles & settings ──────────────────────────────────────────────── + +create table profiles ( + user_id text primary key references users(id), + display_name text not null, + avatar_url text, + contact_email citext, + contact_phone text, + test_mode boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + version integer not null default 1 +); + +create table settings ( + user_id text primary key references users(id), + locale text not null default 'en-US', + timezone text not null default 'UTC', + theme text not null default 'system' check (theme in ('light','dark','system')), + notifications jsonb not null default '{"email": true}', + test_mode boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + version integer not null default 1 +); + +create table app_profiles ( + user_id text not null references users(id), + application_id text not null references applications(id), + tenant_id text not null references tenants(id), + display_handle text check (char_length(display_handle) between 1 and 64), + custom jsonb not null default '{}', + test_mode boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + version integer not null default 1, + primary key (user_id, application_id) +); + +create table app_settings ( + user_id text not null references users(id), + application_id text not null references applications(id), + tenant_id text not null references tenants(id), + overrides jsonb not null default '{}', + test_mode boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + version integer not null default 1, + primary key (user_id, application_id) +); + +-- ── Audit ────────────────────────────────────────────────────────────── + +create table audit_events ( + id text primary key, + action text not null check (action in ( + 'user.created','user.updated','user.email_changed','user.suspended','user.reactivated', + 'user.deleted','user.erasure_requested','user.erasure_cancelled','user.erased', + 'user.password_changed','user.password_reset','user.mfa_enabled','user.mfa_disabled','user.mfa_reset', + 'entitlement.granted','entitlement.disabled','entitlement.revoked','entitlement.expired', + 'entitlement.updated','entitlement.member_scope_changed','entitlement.deleted', + 'role.created','role.updated','role.deleted','role.assigned','role.removed', + 'profile.updated','settings.updated', + 'application.created','application.updated','application.review_status_changed', + 'organization.created','organization.updated','organization.member_invited','organization.member_added', + 'organization.member_removed','organization.member_role_changed', + 'tenant.plan_changed','tenant.subscription_status_changed','tenant.tier_change_requested', + 'tenant.tier_change_request_updated','tenant.tier_changed', + 'api_key.created','api_key.updated','api_key.rotated','api_key.revoked','api_key.restrict_destructive_disabled', + 'webhook.created','webhook.updated','webhook.deleted','webhook.secret_rotated')), + actor_type text not null check (actor_type in ('user','api_key','system')), + actor_id text not null, + target_type text not null, + target_id text not null, + target_user_id text, + application_id text, + organization_id text, + tenant_id text, + before jsonb, + after jsonb, + request_id text, + test_mode boolean not null default false, + timestamp timestamptz not null default now() +); +create index audit_events_target_user on audit_events (target_user_id, timestamp desc); +create index audit_events_application on audit_events (application_id, timestamp desc); +create index audit_events_actor on audit_events (actor_id, timestamp desc); +create index audit_events_target on audit_events (target_type, target_id, timestamp desc); +create index audit_events_organization on audit_events (organization_id, timestamp desc); +create index audit_events_tenant on audit_events (tenant_id, timestamp desc); +create index audit_events_timestamp on audit_events (timestamp desc, id desc); + +-- Append-only (NFR → Audit). The archival and erasure jobs are the only +-- writers after insert; they set app.audit_maintenance = 'on' for their +-- own transaction. Everything else is rejected at the database. +create function substratal_util.audit_events_guard() returns trigger language plpgsql as $$ +begin + if coalesce(current_setting('app.audit_maintenance', true), '') <> 'on' then + raise exception 'audit_events is append-only' using errcode = '42501'; + end if; + if tg_op = 'DELETE' then return old; end if; + return new; +end $$; +create trigger audit_events_append_only before update or delete on audit_events + for each row execute function substratal_util.audit_events_guard(); + +-- ── API keys ─────────────────────────────────────────────────────────── + +create table api_keys ( + id text primary key, + name text not null, + scope text not null, + permissions text[] not null, + mode text not null check (mode in ('live','test')), + intended_use text not null default 'service' check (intended_use in ('service','agent')), + restrict_destructive boolean not null, + secret_hash text not null unique, + secret_hint text not null, + expires_at timestamptz, + created_by_type text not null check (created_by_type = 'user'), + created_by_id text not null, + test_mode boolean not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + version integer not null default 1, + last_used_at timestamptz, + revoked_at timestamptz, + check (test_mode = (mode = 'test')) +); +create index api_keys_scope on api_keys (scope); + +-- ── Webhooks ─────────────────────────────────────────────────────────── + +create table webhook_subscriptions ( + id text primary key, + scope text not null, + tenant_id text references tenants(id), + url text not null, + events text[] not null, + description text, + api_version text not null, + secret_ciphertext text not null, + previous_secret_ciphertext text, + previous_secret_expires_at timestamptz, + status text not null default 'healthy' check (status in ('healthy','unhealthy','disabled')), + consecutive_failures int not null default 0, + unhealthy_since timestamptz, + last_delivery_at timestamptz, + test_mode boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + version integer not null default 1 +); +create index webhook_subscriptions_scope on webhook_subscriptions (scope); + +create table webhook_events ( + id text primary key, + type text not null, + application_id text, + payload jsonb not null, + test_mode boolean not null default false, + created_at timestamptz not null default now(), + dispatched_at timestamptz +); +create index webhook_events_undispatched on webhook_events (created_at) where dispatched_at is null; + +create table webhook_deliveries ( + id text primary key, + subscription_id text not null references webhook_subscriptions(id) on delete cascade, + event_id text not null references webhook_events(id), + attempt int not null, + status text not null check (status in ('pending','succeeded','failed')), + response_status int, + duration_ms int, + error text, + next_retry_at timestamptz, + test_mode boolean not null default false, + created_at timestamptz not null default now() +); +create index webhook_deliveries_subscription on webhook_deliveries (subscription_id, created_at desc); +create index webhook_deliveries_due on webhook_deliveries (next_retry_at) where status = 'pending'; + +-- ── Tenancy requests, erasure, Stripe, idempotency ───────────────────── + +create table tier_change_requests ( + id text primary key, + tenant_id text not null references tenants(id), + from_tier text not null, + from_region text not null, + requested_tier text not null check (requested_tier in ('isolated','dedicated_region')), + requested_region text, + status text not null default 'pending' + check (status in ('pending','scheduled','in_progress','completed','cancelled')), + reason text not null, + notes text, + scheduled_for timestamptz, + requested_by text not null references users(id), + created_at timestamptz not null default now(), + started_at timestamptz, + completed_at timestamptz, + version integer not null default 1 +); +create unique index tier_change_requests_open_key on tier_change_requests (tenant_id) + where status in ('pending','scheduled','in_progress'); + +create table erasure_requests ( + user_id text primary key references users(id), + requested_by_type text not null, + requested_by_id text not null, + reason text, + requested_at timestamptz not null default now(), + scheduled_for timestamptz not null, + status text not null default 'scheduled' check (status in ('scheduled','cancelled','completed')), + completed_at timestamptz +); + +create table stripe_events ( + id text primary key, + type text not null, + tenant_id text references tenants(id), + received_at timestamptz not null default now(), + processed_at timestamptz +); + +create table idempotency_keys ( + caller_id text not null, + idempotency_key text not null check (char_length(idempotency_key) between 1 and 255), + state text not null check (state in ('in_flight','completed')), + request_body_hash text not null, + response_status int, + response_body jsonb, + expires_at timestamptz not null, + primary key (caller_id, idempotency_key) +); + +-- Fixed-window request counters behind NFR → Rate limiting. One upsert per +-- request, in the request's own transaction. Pruned nightly. +create table rate_limit_counters ( + bucket text not null, + window_start timestamptz not null, + count int not null, + primary key (bucket, window_start) +); + +-- ── Triggers ─────────────────────────────────────────────────────────── + +do $$ +declare t text; +begin + foreach t in array array['users','organizations','tenants','applications','entitlements','roles', + 'profiles','settings','app_profiles','app_settings','api_keys','webhook_subscriptions'] + loop + execute format('create trigger %I before update on %I for each row execute function substratal_util.touch_updated_at()', + t || '_touch_updated_at', t); + end loop; + foreach t in array array['users','organizations','applications','entitlements','roles','profiles', + 'settings','app_profiles','app_settings','api_keys','webhook_subscriptions', + 'tier_change_requests'] + loop + execute format('create trigger %I before update on %I for each row execute function substratal_util.bump_version()', + t || '_bump_version', t); + end loop; +end $$; + +-- ── Row-Level Security: test/live isolation ──────────────────────────── +-- +-- FORCE applies the policy to the table owner too, which is the role the +-- API connects as. A superuser bypasses RLS regardless, which is why the +-- local Docker setup connects as a non-superuser role (docker/postgres-init.sql). + +do $$ +declare t text; +begin + foreach t in array array['users','organizations','organization_memberships','sessions','entitlements', + 'user_role_assignments','profiles','settings','app_profiles','app_settings', + 'audit_events','webhook_subscriptions','webhook_events','webhook_deliveries'] + loop + execute format('alter table %I enable row level security', t); + execute format('alter table %I force row level security', t); + execute format('create policy %I on %I using (substratal_util.mode_visible(test_mode)) with check (substratal_util.mode_visible(test_mode))', + t || '_mode', t); + end loop; +end $$; + +-- api_keys deliberately has no mode policy: the schema applies it to tables +-- a test-mode credential can write, and an API Key can never create API +-- Keys. test_mode there marks the mode the key acts in, and a live owner +-- manages their Application's test keys alongside its live ones. + +-- Roles: live definitions (including the four seed Roles) are shared +-- reference data visible in both modes, the same exception Applications +-- get; Roles a test credential creates are visible only in test mode. +alter table roles enable row level security; +alter table roles force row level security; +create policy roles_mode on roles + using (not test_mode or substratal_util.mode_visible(test_mode)) + with check (substratal_util.mode_visible(test_mode)); + +-- ── Seed data the platform can't run without ─────────────────────────── + +-- The four platform seed Roles (Domain Model → Platform role grants). +insert into roles (id, name, scope, description, permissions, seed) values + ('role_platform_superadmin', 'superadmin', 'platform', 'Every platform permission.', + '{users.list,users.manage,entitlements.manage,applications.manage,roles.manage,organizations.manage,billing.manage,billing.refund,audit.view,webhooks.manage,api_keys.manage,tenants.manage}', true), + ('role_platform_support', 'support', 'platform', 'Customer support.', + '{users.list,entitlements.manage,audit.view,tenants.manage,billing.manage}', true), + ('role_platform_billing_admin', 'billing_admin', 'platform', 'Platform billing administration.', + '{billing.manage,billing.refund,audit.view,users.list}', true), + ('role_platform_member', 'member', 'platform', 'Default for every User; grants nothing beyond self-service.', + '{}', true); + +-- Substratal's reserved internal account: the owner of Substratal-run +-- "system" Applications (Database Schema → applications). It has no +-- identity, so it can never sign in. +insert into users (id, email, email_verified, status) + values ('usr_01JAG0SYSTEM00000000000000', 'system@substratal.invalid', true, 'active'); + +-- Its Tenant: Substratal's own Applications aren't a Starter customer, so +-- they aren't held to Starter's 1-Application cap. +insert into tenants (id, owner_type, owner_user_id, plan) + values ('tnt_01JAG1SYSTEM00000000000000', 'user', 'usr_01JAG0SYSTEM00000000000000', 'enterprise'); diff --git a/migrations/embed.go b/migrations/embed.go new file mode 100644 index 0000000..21d1559 --- /dev/null +++ b/migrations/embed.go @@ -0,0 +1,10 @@ +// Package migrations embeds the numbered SQL files so every binary that +// migrates (cmd/migrate, tests) carries the exact same set. +package migrations + +import "embed" + +// FS holds every NNNN_name.sql file in this directory. +// +//go:embed *.sql +var FS embed.FS diff --git a/scripts/quickstart.sh b/scripts/quickstart.sh new file mode 100755 index 0000000..320a6ae --- /dev/null +++ b/scripts/quickstart.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# The docs/quickstart.md walkthrough as a script, against a running API +# (default: the local devserver). It's the Phase 1 acceptance test from +# PLAN.md, so CI and `make quickstart` run it on every change. +# +# SUBSTRATAL_API=http://localhost:8080/v1 TOKEN= scripts/quickstart.sh +# +# Without TOKEN it signs in with ADMIN_EMAIL / ADMIN_PASSWORD. +set -euo pipefail + +API="${SUBSTRATAL_API:-http://localhost:8080/v1}" +need() { command -v "$1" >/dev/null || { echo "quickstart: $1 is required" >&2; exit 1; }; } +need curl +need jq + +call() { # method path [json] + local method=$1 path=$2 body=${3:-} + local args=(-sS -X "$method" "$API$path" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" + -w '\n%{http_code}') + [[ -n "$body" ]] && args+=(-d "$body") + [[ "$method" == POST && "$path" == */entitlements ]] && args+=(-H "Idempotency-Key: $(uuidgen 2>/dev/null || date +%s%N)") + local out; out=$(curl "${args[@]}") + STATUS=${out##*$'\n'}; BODY=${out%$'\n'*} +} +expect() { # status jq-filter expected + if [[ "$STATUS" != "$1" ]]; then echo "FAIL: HTTP $STATUS, want $1: $BODY" >&2; exit 1; fi + if [[ $# -ge 3 ]]; then + local got; got=$(jq -r "$2" <<<"$BODY") + [[ "$got" == "$3" ]] || { echo "FAIL: $2 = $got, want $3: $BODY" >&2; exit 1; } + fi +} +step() { printf '\n== %s\n' "$*"; } + +if [[ -z "${TOKEN:-}" ]]; then + TOKEN=$(curl -sS "$API/auth/login" -H "Content-Type: application/json" \ + -d "$(jq -n --arg e "${ADMIN_EMAIL:?set TOKEN or ADMIN_EMAIL/ADMIN_PASSWORD}" --arg p "${ADMIN_PASSWORD:?}" '{email:$e,password:$p}')" | + jq -r .access_token) +fi + +SLUG="timetrack-$(date +%s)" +APP="app_$SLUG" +step "0. An Application to grant (TimeTrack)" +call POST /applications "$(jq -n --arg s "$SLUG" '{slug:$s, name:"TimeTrack", launch_url:"https://timetrack.example.com/launch", + owner_user_id:"usr_01JAG0SYSTEM00000000000000", available_app_roles:["admin","member"], + permissions:[{key:("app."+$s+".export")}]}')" +expect 201 .id "$APP" + +step "1. Create a user" +call POST /users "$(jq -n --arg e "jordan+$(date +%s)@example.com" '{email:$e, status:"invited"}')" +expect 201 .status invited +USER=$(jq -r .id <<<"$BODY") + +step "2. Confirm they own nothing yet" +call GET "/users/$USER/entitlements" +expect 200 '.data | length' 0 + +step "3. Grant access to an app" +call POST "/users/$USER/entitlements" "$(jq -n --arg a "$APP" '{application_id:$a, source:"admin_grant"}')" +expect 201 .status active +ENT=$(jq -r .id <<<"$BODY") + +step "4. Check what they can actually do" +call GET "/users/$USER/apps/$APP/effective-permissions" +expect 200 .allowed false +call PATCH "/users/$USER" '{"status":"active"}' +expect 200 .status active +call GET "/users/$USER/apps/$APP/effective-permissions" +expect 200 .allowed true + +step "5. Assign a role inside the app" +call PATCH "/roles/role_${SLUG}_admin" "$(jq -n --arg s "$SLUG" '{permissions:[("app."+$s+".export")]}')" +expect 200 +call POST "/users/$USER/roles/role_${SLUG}_admin" '{}' +expect 201 +call GET "/users/$USER/apps/$APP/effective-permissions" +expect 200 '.effective_permissions[0]' "app.$SLUG.export" + +step "6. Turn it off" +call PATCH "/entitlements/$ENT" '{"status":"disabled","disabled_reason":"quickstart_demo"}' +expect 200 .status disabled +call GET "/users/$USER/apps/$APP/effective-permissions" +expect 200 .allowed false +expect 200 '.effective_permissions | length' 0 + +printf '\nquickstart: all steps passed\n' From 13b4b6e6a340713542fc5b92f7416b47cc42f53e Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Tue, 6 Oct 2026 05:14:00 -0700 Subject: [PATCH 2/3] Document the implementation: README, DECISIONS #32-#37, PLAN, Changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: Status (implemented, not deployed), stack table and layout as built, real local ports, devserver/jobs/keys behavior, the two database backends as implemented, make targets, and a concrete one-time setup. - DECISIONS.md #32-#37 (Proposed — confirm): org/tenant RLS semantics, inline Stripe processing, JWKS without CloudFront, seat cap on scope changes, test mode and seats, audit immutability via trigger. - PLAN.md build status; DEPLOYMENT.md pointers to #33/#34. - Changelog: the openapi.yaml fixes the contract tests found. Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 56 ++++++++++++++++++++++ DEPLOYMENT.md | 4 +- PLAN.md | 6 ++- README.md | 116 +++++++++++++++++++++++----------------------- docs/changelog.md | 12 +++++ 5 files changed, 131 insertions(+), 63 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index d20cb2e..9a74479 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -26,6 +26,12 @@ Status meanings: | # | Question | Status | Where it lives in the spec | |---|---|---|---| | 31 | [Published prices and billing options](#31-published-prices-and-billing-options) | 🟡 Open | [Pricing](https://adron.github.io/substratalapps.com/pricing/), [Pricing → Stripe catalog](https://adron.github.io/substratalapps.com/pricing/#stripe-catalog) | +| 32 | [Organization and Tenant Row-Level Security](#32-organization-and-tenant-row-level-security) | 🟡 Proposed — confirm | [NFR → Multi-tenancy](https://adron.github.io/substratalapps.com/non-functional-requirements/#multi-tenancy) | +| 33 | [Stripe webhook processing: inline, not SQS](#33-stripe-webhook-processing-inline-not-sqs) | 🟡 Proposed — confirm | [DEPLOYMENT.md → Webhook handling](DEPLOYMENT.md#webhook-handling) | +| 34 | [JWKS without CloudFront at Tier 0](#34-jwks-without-cloudfront-at-tier-0) | 🟡 Proposed — confirm | [DEPLOYMENT.md → Build checklist](DEPLOYMENT.md#build-checklist), [NFR → SLOs](https://adron.github.io/substratalapps.com/non-functional-requirements/#service-level-objectives) | +| 35 | [Seat cap on member-scope changes](#35-seat-cap-on-member-scope-changes) | 🟡 Proposed — confirm | [Pricing → Enforcement](https://adron.github.io/substratalapps.com/pricing/#enforcement) | +| 36 | [Test mode and seats](#36-test-mode-and-seats) | 🟡 Proposed — confirm | [Pricing → What "seat" means](https://adron.github.io/substratalapps.com/pricing/#what-seat-means-here) | +| 37 | [Audit immutability without a second database role](#37-audit-immutability-without-a-second-database-role) | 🟡 Proposed — confirm | [NFR → Audit](https://adron.github.io/substratalapps.com/non-functional-requirements/#audit), [Database Schema → audit_events](https://adron.github.io/substratalapps.com/domain-model/database-schema/#audit_events) | ### 31. Published prices and billing options @@ -38,6 +44,56 @@ Status meanings: **Current assumption:** prices as listed, monthly USD billing only, no trial, and no card required for Starter. None of this blocks the build: prices are configuration in Stripe, not code, and each option is additive later. +### 32. Organization and Tenant Row-Level Security + +Surfaced by the implementation (2026-10-06). + +**Question:** [NFR → Multi-tenancy](https://adron.github.io/substratalapps.com/non-functional-requirements/#multi-tenancy) asks for RLS policies keyed on `app.current_org_id` and `app.current_tenant_id` that fail closed. Which org and tenant does a request set when the caller legitimately spans many: a User in several Organizations, a platform admin listing every Entitlement, a job sweeping every Tenant? + +**What's built:** RLS enforces **test/live isolation** (`app.current_test_mode`) on every table a test credential can write, on both database backends. Tenant and Organization scoping is enforced in queries (app-confined keys, owner checks, explicit `tenant_id`/`organization_id` filters) and covered by integration tests, not by RLS. + +**Proposal:** keep that for the `shared` tier, and add per-request allowed-set policies (`app.allowed_tenant_ids`, with an explicit platform bypass) only when an `isolated`/`dedicated_region` Tenant first needs a second line of defense. **Alternative:** design and add both policies now. + +### 33. Stripe webhook processing: inline, not SQS + +**Question:** [DEPLOYMENT.md → Webhook handling](DEPLOYMENT.md#webhook-handling) says to record the event, return `200`, and process from SQS. + +**What's built:** the handler records the event id (insert-or-skip, so duplicates return `200` at once), then processes it in the same invocation, re-fetching the Subscription from Stripe. A failure leaves `processed_at` null, and the `stripe-events` job (every 10 minutes) re-fetches and reprocesses it. + +**Proposal:** keep it inline; processing is one Stripe read and one transaction, well within Stripe's timeout, and the retry job gives the same durability as a queue. **Alternative:** add the SQS hop as specified. + +### 34. JWKS without CloudFront at Tier 0 + +**Question:** the build checklist puts CloudFront in front of `/.well-known/jwks.json`, but JWKS lives on `api.substratalapps.com`, so caching just that path means putting CloudFront in front of the whole API, which [Scale-out](DEPLOYMENT.md#scale-out) lists as a later trigger. + +**What's built:** JWKS is served by the API Lambda with `Cache-Control: public, max-age=3600`, and Applications cache it as the spec already tells them to. + +**Proposal:** defer CloudFront to the Scale-out trigger. **Alternative:** add a CloudFront distribution for the whole domain now, to hold the 99.99% JWKS SLO through an API outage. + +### 35. Seat cap on member-scope changes + +**Question:** [Pricing → Enforcement](https://adron.github.io/substratalapps.com/pricing/#enforcement) lists "narrowing to an `allowlist` that includes new people" among writes rejected with `409 plan_limit_reached`. + +**What's built:** personal grants, re-enables, and new org-wide grants are rejected at the cap. A `member_scope` change is never rejected; members it would add beyond a Starter Tenant's cap get `member_decision: seat_limit` instead, the same treatment the spec gives a member who joins past the cap. + +**Proposal:** confirm that; it never cuts anyone off and matches the membership rule. **Alternative:** reject the scope change with `409`. + +### 36. Test mode and seats + +**Question:** seats count "non-test-mode Users". Does a Starter Tenant's seat cap gate test-mode grants? + +**What's built:** no. Test-mode Users never count as seats, and seat limits never apply to test-mode writes, so live usage can't block integration tests and test traffic can't consume paid seats. + +**Proposal:** confirm. **Alternative:** a separate test-mode cap. + +### 37. Audit immutability without a second database role + +**Question:** [NFR → Audit](https://adron.github.io/substratalapps.com/non-functional-requirements/#audit) gives the archival and erasure jobs a separate database role holding `UPDATE`/`DELETE` on `audit_events`. With the Data API, a second role means a second cluster secret and credential path. + +**What's built:** a trigger rejects every `UPDATE`/`DELETE` on `audit_events` unless the transaction sets `app.audit_maintenance = 'on'`, which only those two jobs do. The guarantee is enforced by the database for every caller, including a direct SQL session. + +**Proposal:** confirm the trigger. **Alternative:** add the second role and secret, and give only the jobs Lambda access to it. + --- ## Resolved diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index a88926e..6c893a0 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -211,7 +211,7 @@ POST /internal/stripe/webhook Not under `/v1`, the same reasoning as the MCP server's `/mcp` route: it isn't a REST resource a caller invokes, it's an inbound event sink with its own Stripe-defined contract and versioning. It's not Bearer-authenticated either. It verifies the `Stripe-Signature` header against the raw body with `STRIPE_WEBHOOK_SECRET` (a 5-minute tolerance), the same shape as this API's own outbound [webhook signatures](https://adron.github.io/substratalapps.com/api-reference/webhooks/#verifying-the-signature). -**Processing model:** insert the Stripe event id into `stripe_events` (on conflict do nothing, so a duplicate returns `200` immediately), return `200`, and process asynchronously from SQS. Each handler **re-fetches the Subscription from Stripe** rather than trusting the event payload, which makes out-of-order delivery harmless: whatever arrives last, the Tenant ends up matching Stripe's current state. +**Processing model:** insert the Stripe event id into `stripe_events` (on conflict do nothing, so a duplicate returns `200` immediately), return `200`, and process asynchronously from SQS (as built, processing runs inline in the same invocation, with a retry job for failures; see [DECISIONS.md #33](DECISIONS.md#33-stripe-webhook-processing-inline-not-sqs)). Each handler **re-fetches the Subscription from Stripe** rather than trusting the event payload, which makes out-of-order delivery harmless: whatever arrives last, the Tenant ends up matching Stripe's current state. | Stripe event | Effect | |---|---| @@ -289,7 +289,7 @@ The order this gets stood up in, once API implementation begins: 3. KMS keys: one asymmetric RSA-2048 signing key for JWTs (JWKS is published from its public half), and one symmetric key for encrypting TOTP and webhook secrets. Secrets Manager secrets: the Aurora master credential, and Stripe's secret key + webhook-signing secret (test-mode keys first — see [Stripe Billing](#stripe-billing)). Aurora Serverless v2 cluster with Data API enabled, minimum 0.5 / maximum capacity set deliberately. 4. IAM: one execution role per Lambda function (API handlers, MCP server, Stripe webhook handler, webhook worker, scheduled-jobs), each scoped to only the resources it actually needs — no shared mega-role. 5. API Gateway HTTP API + custom domain mapping; Lambda handlers deployed behind it, implementing the [API Reference](https://adron.github.io/substratalapps.com/api-reference/) / [openapi.yaml](https://adron.github.io/substratalapps.com/openapi.yaml) contract. -6. SQS queues (webhook fan-out/delivery, email, Stripe events) and their worker Lambdas; EventBridge Scheduler rules for the entitlement-expiry sweep (every 5 minutes), the webhook outbox dispatcher, the erasure cascade (hourly), the seat sync (daily 00:15 UTC), test-mode purge, expired-token and idempotency-key cleanup (nightly), and [audit log archival](#audit-log-archival). CloudFront in front of `/.well-known/jwks.json` (1-hour cache). +6. SQS queues (webhook fan-out/delivery, email, Stripe events) and their worker Lambdas; EventBridge Scheduler rules for the entitlement-expiry sweep (every 5 minutes), the webhook outbox dispatcher, the erasure cascade (hourly), the seat sync (daily 00:15 UTC), test-mode purge, expired-token and idempotency-key cleanup (nightly), and [audit log archival](#audit-log-archival). CloudFront in front of `/.well-known/jwks.json` (1-hour cache; deferred, see [DECISIONS.md #34](DECISIONS.md#34-jwks-without-cloudfront-at-tier-0)). 7. Stripe account: run the catalog sync script (test mode first), configure the Customer Portal (no plan switching), enable Stripe Tax, register the `/internal/stripe/webhook` endpoint for the events in [Webhook handling](#webhook-handling). 8. CloudWatch Logs with explicit retention on every log group; a small set of alarms (error rate, Lambda throttling, Aurora ACU near max) in addition to the billing guardrails from step 1. 9. CI/CD via OIDC federation (GitHub Actions → an AWS deploy role) — no long-lived IAM user access keys committed anywhere. diff --git a/PLAN.md b/PLAN.md index d7f15a5..2ee1920 100644 --- a/PLAN.md +++ b/PLAN.md @@ -36,8 +36,8 @@ Before any endpoint handler exists. Maps directly to [DEPLOYMENT.md → Build ch - [ ] AWS account, `us-east-1`, under Organizations — [DEPLOYMENT.md → AWS account & region](DEPLOYMENT.md#aws-account--region). - [ ] **AWS Budgets + Cost Anomaly Detection first**, before any billable resource — [DEPLOYMENT.md → Cost guardrails](DEPLOYMENT.md#cost-guardrails). Not optional, not step 2. - [ ] Aurora Serverless v2 cluster, Data API enabled, Secrets Manager credential. -- [ ] The local-dev repository/data-access interface described in [DEPLOYMENT.md → Local development](DEPLOYMENT.md#local-development), *before* the first table is written against it — retrofitting this seam after business logic exists is real rework, building it first is not. -- [ ] CI running the [Testing strategy](https://adron.github.io/substratalapps.com/non-functional-requirements/#testing-strategy) categories (contract tests against `openapi.yaml`, unit tests on `effective_permissions`, integration tests against local Postgres) against an empty schema — so the pipeline exists before there's much to test, not retrofitted. +- [x] The local-dev repository/data-access interface described in [DEPLOYMENT.md → Local development](DEPLOYMENT.md#local-development), *before* the first table is written against it — retrofitting this seam after business logic exists is real rework, building it first is not. +- [x] CI running the [Testing strategy](https://adron.github.io/substratalapps.com/non-functional-requirements/#testing-strategy) categories (contract tests against `openapi.yaml`, unit tests on `effective_permissions`, integration tests against local Postgres) against an empty schema — so the pipeline exists before there's much to test, not retrofitted. **Exit criteria:** `GET /v1/health` deployed and returning `200` through the real Lambda → API Gateway → (nothing else yet) path. Proves the skeleton, not the product. @@ -89,6 +89,8 @@ Not a phase — these apply from Phase 0 onward and should never be "added later ## What's still open, and what it blocks +**Build status (2026-10-06):** the code for every phase's API surface is implemented, including Phase 3's hosted PKCE endpoints (SSO still returns `501` until the broker integration, as specified), and the Quickstart acceptance test passes against the devserver. The Phase 0 *infrastructure* items above are written as Terraform (`infra/terraform/`) but not yet applied; see [README → One-time setup](README.md#one-time-setup-phase-0). Decisions #32–#37 record choices the implementation made where the spec was silent or self-contradictory. + See [DECISIONS.md](DECISIONS.md) for the current list. Decisions #1–#30 are resolved, and each answer is written into the spec page it governs. #16–#30 were confirmed on 2026-10-05, and #29 was applied: per-user, per-Application paths use `/apps/{appId}`. The one open question, [#31](DECISIONS.md#31-published-prices-and-billing-options), covers the final Pricing dollar amounts plus annual plans, a Team trial, and whether Starter needs a card on file. **It blocks nothing in any phase:** prices are Stripe configuration, not code, and each billing option is additive. Settle it before the Phase 2 Billing work goes live to paying customers. diff --git a/README.md b/README.md index c0f7966..ee671cd 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Deliberately **staying** on the docs site despite being borderline: `compliance. ## Status -**Specification-complete for an MVP build; implementation has not started.** The stack is decided (Go on Lambda, Terraform; see [Technology stack](#technology-stack)), and day-to-day work happens on the `dev` branch (see [Branches, CI, and deployment](#branches-ci-and-deployment)). The API described at [adron.github.io/substratalapps.com](https://adron.github.io/substratalapps.com/) is a target contract, not a running service — there is no code in this repository yet beyond the documentation site itself and this planning layer. See [PLAN.md](PLAN.md) for the build order and [DECISIONS.md](DECISIONS.md) for questions still awaiting a decision. Decisions #1–#30 are resolved and written into the spec pages they govern; the only open question (#31, final prices and billing options) doesn't block the build. Check `DECISIONS.md` before starting a phase, since real implementation can surface a new question nobody asked yet. +**Implemented; not yet deployed.** Every operation in [`docs/openapi.yaml`](docs/openapi.yaml) is implemented in Go (see [Technology stack](#technology-stack)), along with the webhook worker, Stripe billing sync, scheduled jobs, the MCP server, CI/CD, and Terraform for AWS. The integration suite runs against real Postgres and validates every response against `openapi.yaml`. Production doesn't exist yet: the [one-time setup](#one-time-setup-phase-0) hasn't been run, so `deploy.yml` skips with a notice until it is. Day-to-day work happens on the `dev` branch (see [Branches, CI, and deployment](#branches-ci-and-deployment)). See [DECISIONS.md](DECISIONS.md) for questions the build surfaced; none of them blocks the build. ## Technology stack @@ -40,14 +40,14 @@ Decided 2026-10-06. The API is written in **Go**, deployed to **AWS Lambda**, an |---|---|---| | Language | **Go** (latest stable release, pinned in `go.mod`) | Cold starts in the tens of milliseconds and small memory use on Lambda. That directly serves the p95 < 150 ms target for `effective-permissions`, `app-tokens`, and `oauth/token` in [Non-Functional Requirements](https://adron.github.io/substratalapps.com/non-functional-requirements/), and the cost-first rules in [DEPLOYMENT.md](DEPLOYMENT.md#cost-principles). Strongly typed, stable for years, and plain enough that a one-person team and an AI assistant can both read and change it safely. | | Lambda runtime | `provided.al2023` on **arm64** (Graviton) | Go compiles to one static `bootstrap` binary. arm64 is cheaper per GB-second than x86 for the same work. | -| HTTP layer | Standard library `net/http`, with server interfaces and types **generated from [`docs/openapi.yaml`](docs/openapi.yaml)** by `oapi-codegen` (strict server mode) | The spec is the contract. Generating from it means an endpoint whose shape doesn't match `openapi.yaml` fails to compile, and contract tests catch the runtime side. | +| HTTP layer | Standard library `net/http`. The route table, path/query/header binding, and the `ServerInterface` every operation must implement are **generated from [`docs/openapi.yaml`](docs/openapi.yaml)** by `oapi-codegen`. Request and response bodies are hand-written in `internal/core`. | The spec is the contract: an operation without a handler doesn't compile. The generated *body* models can't express explicit JSON `null`s or PATCH field presence, both of which Conventions requires, so bodies are hand-written and the contract tests validate every response against `openapi.yaml` instead. | | Lambda adapter | `aws-lambda-go`, plus a thin adapter that turns API Gateway HTTP API events into `http.Request`s | The **same** `http.Handler` serves requests on a laptop and in Lambda. Nothing in handler code knows which one it's running in. | -| Database | **PostgreSQL 16**. In AWS: Aurora Serverless v2 through the **RDS Data API**. Locally and in CI: plain Postgres through **`pgx`**. | Both sit behind one repository interface, as [DEPLOYMENT.md → Local development](DEPLOYMENT.md#local-development) requires. See [The two database backends](#the-two-database-backends). | +| Database | **PostgreSQL 16**. In AWS: Aurora Serverless v2 through the **RDS Data API**. Locally and in CI: plain Postgres through **`pgx`**. | Both implement one small driver interface (`internal/db`), as [DEPLOYMENT.md → Local development](DEPLOYMENT.md#local-development) requires. See [The two database backends](#the-two-database-backends). | | Migrations | Plain, numbered SQL files in `migrations/`, applied by an in-repo `cmd/migrate` tool | Same tool, same files, same tracking table (`schema_migrations`) in every environment. An in-repo runner is used instead of a third-party tool because the common Go migration tools need a `database/sql` driver, and the Data API has no first-party one. | | Crypto | `golang.org/x/crypto/argon2` (Argon2id), standard-library HMAC/RSA, AWS KMS for JWT signing and secret encryption | Matches [Non-Functional Requirements → Security](https://adron.github.io/substratalapps.com/non-functional-requirements/) exactly, with no native add-ons. | | AWS access | AWS SDK for Go v2 (Data API, KMS, SQS, Secrets Manager, S3, SES) | | | Billing | `stripe-go`, Stripe's official Go library | | -| MCP server | Go, using the official MCP Go SDK | Per [DEPLOYMENT.md → MCP server](DEPLOYMENT.md#mcp-server), it only proxies HTTPS calls to `/v1`, so it needs no special language features. | +| MCP server | Go, implementing the buffered-JSON subset of MCP Streamable HTTP directly (`internal/mcp`). Tools are generated from `openapi.yaml` by `cmd/mcpgen`. | The spec requires a stateless, signed `Mcp-Session-Id` and one invocation per call; the official SDK keeps sessions in memory, which doesn't fit a stateless Lambda. It only proxies HTTPS calls to `/v1`. | | Infrastructure | **Terraform**, remote state in S3 (native S3 state locking, no DynamoDB table) | Language-neutral and widely known, and `terraform plan` output can be reviewed on the pull request before it reaches production. Works with OpenTofu too. | | CI/CD | **GitHub Actions**, deploying to AWS through **OIDC federation** | No long-lived AWS keys anywhere, per [DEPLOYMENT.md → Build checklist](DEPLOYMENT.md#build-checklist) step 9. | | Local services | **Docker Compose**: Postgres 16, LocalStack (SQS, KMS, Secrets Manager, S3), Mailpit (captures outgoing email) | The whole API runs on a laptop with no AWS account and no internet beyond Stripe test mode. | @@ -56,41 +56,34 @@ Considered and not chosen: **TypeScript on Node.js** (fastest early development ### Repository layout -The API code lives in this same repository, next to the docs. That matters: a change to the API contract (`docs/openapi.yaml`), the code that implements it, and the docs that describe it all land in **one pull request** and ship in **one merge**. Planned layout: +The API code lives in this same repository, next to the docs. That matters: a change to the API contract (`docs/openapi.yaml`), the code that implements it, and the docs that describe it all land in **one pull request** and ship in **one merge**. ```text . -├── cmd/ # One main package per Lambda function, each with its own IAM role -│ ├── api/ # REST handlers for /v1/* -│ ├── mcp/ # MCP server at /mcp -│ ├── stripe-webhook/ # /internal/stripe/webhook -│ ├── webhook-worker/ # SQS consumer that delivers outbound webhooks -│ ├── jobs/ # EventBridge-scheduled jobs (expiry sweep, cleanup, archival…) -│ ├── migrate/ # Schema migration runner (pgx or Data API backend) -│ └── devserver/ # Local only: every route above on one port, plus in-process workers +├── cmd/ +│ ├── api/ mcp/ stripe-webhook/ webhook-worker/ jobs/ # One Lambda each, each with its own IAM role +│ ├── devserver/ # Local only: every route on one port, workers in-process +│ ├── migrate/ # Schema migration runner (pgx or Data API backend) +│ ├── admin/ # One-time superadmin bootstrap (not an API) +│ ├── mcpgen/ # openapi.yaml → internal/mcp/tools.json +│ └── stripe-catalog/ # Creates the Stripe Products/Prices by lookup key ├── internal/ │ ├── api/gen/ # Generated from docs/openapi.yaml. Never edited by hand. -│ ├── handlers/ # Implements the generated strict-server interface -│ ├── access/ # allow(user, application, permission): the Access Control algorithm -│ ├── store/ # Repository interfaces, one per aggregate -│ │ ├── pg/ # pgx implementation (local, CI) -│ │ └── dataapi/ # RDS Data API implementation (AWS) -│ ├── auth/ crypto/ billing/ webhooks/ email/ audit/ -│ └── platform/ # Config, logging, Lambda adapter, AWS clients -├── migrations/ # 0001_init.sql, 0002_….sql: plain SQL, forward-only -├── seeds/ # Local and test data, built from the API Reference examples -├── infra/terraform/ -│ ├── bootstrap/ # Applied once, by hand: state bucket, OIDC provider, deploy roles -│ ├── modules/ # lambda_function, http_api, aurora, queues, budgets, … -│ └── prod/ # The production environment -├── docker-compose.yml -├── Makefile -├── .env.example -├── docs/ # The API spec site (unchanged) -└── .github/workflows/ - ├── ci.yml # Every push to dev and every PR into main - ├── deploy.yml # Every merge into main → production - └── docs.yml # Docs versioning and Pages (already in place) +│ ├── core/ # Every operation, the request pipeline, jobs' domain logic +│ ├── access/ # allow(user, application, permission), as a pure function +│ ├── db/ # Driver interface; pg/ (pgx), dataapi/ (RDS Data API), migrate/ +│ ├── auth/ # Argon2id, password policy, TOTP, JWT (KMS or local), encryption +│ ├── schema/ # The settings_schema JSON Schema subset +│ ├── httpx/ # Error envelope, cursors, ETag, PATCH presence +│ ├── webhooks/ billing/ jobs/ mcp/ email/ ids/ +│ ├── platform/ # config, wire (dependency wiring), lambdahttp (adapter) +│ └── testenv/ # Real-Postgres test harness + contract validation +├── migrations/ # 0001_init.sql, …: plain SQL, forward-only +├── scripts/quickstart.sh # docs/quickstart.md as a script +├── infra/terraform/ # bootstrap/ (once, by hand), modules/, prod/ +├── docker-compose.yml docker/postgres-init.sql Makefile .env.example +├── docs/ # The API spec site +└── .github/workflows/ # ci.yml, deploy.yml, rollback.yml, docs.yml ``` ## Branches, CI, and deployment @@ -193,10 +186,13 @@ For an urgent fix while `dev` holds unreleased work: The steps above assume these exist. They're part of [PLAN.md → Phase 0](PLAN.md#phase-0--infrastructure-skeleton), done once, by hand: -1. The AWS account, region, and **cost guardrails first**, per [DEPLOYMENT.md → Cost guardrails](DEPLOYMENT.md#cost-guardrails). -2. Apply `infra/terraform/bootstrap/` from your own machine with admin credentials. It creates the S3 state bucket, the GitHub OIDC identity provider, the read-only `plan` role, and the `deploy` role, each trusting only this repository and the right branch or environment. This is the only Terraform ever applied from a laptop. -3. In GitHub: create the `production` environment, add the `main` ruleset, and set repository variables for the role ARNs, region, and state bucket. These are not secrets, because OIDC needs no stored credentials. -4. Seed the first `superadmin` with the one-time admin command, as [Non-Functional Requirements → Security](https://adron.github.io/substratalapps.com/non-functional-requirements/) requires, since the public API can't create it. +1. A dedicated AWS account in `us-east-1` ([DEPLOYMENT.md → AWS account & region](DEPLOYMENT.md#aws-account--region)), with its default VPC in place (Aurora's subnets come from it; Lambda never joins a VPC), and a Route 53 hosted zone for `substratalapps.com`. +2. From your own machine, with admin credentials: `cd infra/terraform/bootstrap && terraform init && terraform apply -var billing_email=you@example.com`. This creates the **cost guardrails first** (warn and hard budgets with a circuit-breaker action, Cost Anomaly Detection, a billing alarm), then the state and artifact buckets, the GitHub OIDC provider, and the read-only `plan` and `deploy` roles. It's the only Terraform ever applied from a laptop. +3. In GitHub: create the `production` environment (deployments from `main` only), add the `main` ruleset, and set repository **variables** from the bootstrap outputs: `AWS_PLAN_ROLE_ARN`, `AWS_DEPLOY_ROLE_ARN`, `AWS_REGION`, `TF_STATE_BUCKET`, `ARTIFACT_BUCKET`, plus `API_DOMAIN` (`api.substratalapps.com`). None are secrets; OIDC needs no stored credentials. +4. Merge into `main`. The first deploy creates the stack and then migrates the new cluster. +5. Put the Stripe keys into the `substratal-prod/app` secret (`stripe_secret_key`, `stripe_webhook_secret`; Terraform never overwrites them), run `make stripe-catalog` against test mode first, and register `https://api.substratalapps.com/internal/stripe/webhook` in Stripe. Request SES production access (about a day). +6. Seed the first `superadmin`, which the public API can't create by design: `DATABASE_BACKEND=dataapi DB_CLUSTER_ARN=… DB_SECRET_ARN=… go run ./cmd/admin bootstrap -email you@example.com` (ARNs from `terraform output`). +7. Optional: seed a smoke-test User and Application, and set `SMOKE_USER_ID`, `SMOKE_APP_ID` (variables) and `SMOKE_API_KEY` (secret) so every deploy also checks `effective-permissions` through the Data API. `main` stays the repository's default branch. It's what's in production, and links throughout the docs point at `blob/main/...`. Local checkouts and day-to-day work use `dev`. @@ -206,7 +202,8 @@ Everything below runs on a laptop with no AWS account. The goal is that `make up ### Prerequisites -- **Go**, the version pinned in `go.mod`. Code generators and linters are pinned as `tool` dependencies in `go.mod`, so `go tool oapi-codegen` and the rest need no separate installs. +- **Go**, the version pinned in `go.mod`. `oapi-codegen` is pinned as a `tool` dependency, so `make gen` needs no separate install. `golangci-lint` is optional locally (CI runs it). +- **jq**, for `make quickstart`. - **Docker** with Docker Compose v2. - **Terraform**, only if you're changing `infra/`. - **Stripe CLI**, only if you're working on billing (`stripe login` once, test mode). @@ -221,37 +218,37 @@ git checkout dev # all work happens on dev cp .env.example .env # local defaults, safe to use as-is make up # Postgres, LocalStack, Mailpit make migrate # apply migrations/ to local Postgres -make seed # local superadmin, sample Application, Quickstart data +make seed # a local superadmin (ADMIN_EMAIL / ADMIN_PASSWORD from .env) make run # devserver on http://localhost:8080 ``` -Then the [Quickstart](https://adron.github.io/substratalapps.com/quickstart/) works locally as written, with `SUBSTRATAL_API=http://localhost:8080/v1`. +Then `make quickstart` runs the [Quickstart](https://adron.github.io/substratalapps.com/quickstart/) against it (it creates its own TimeTrack Application), and `curl` works as written with `SUBSTRATAL_API=http://localhost:8080/v1`. Integration tests need only `make up`: each test package creates, migrates, and drops its own database. ### What runs where | Service | Local | In AWS | |---|---|---| | REST API, `/mcp`, `/internal/stripe/webhook` | `cmd/devserver`: every route on `:8080`, the same handlers the Lambdas use | One Lambda per function behind API Gateway | -| Database | Postgres 16 container on `:5432` through pgx | Aurora Serverless v2 through the Data API | -| Queues (webhook delivery, email, Stripe events) | LocalStack SQS. The devserver runs the workers in-process, polling the same queues. | SQS → worker Lambdas | -| Scheduled jobs | Run on demand: `make job name=entitlement-expiry` | EventBridge Scheduler → `jobs` Lambda | -| JWT signing, secret encryption | LocalStack KMS: an RSA-2048 signing key and a symmetric key, created by `make up` | AWS KMS | -| Secrets | `.env`, or LocalStack Secrets Manager | Secrets Manager | -| Email | Mailpit. Every email the API sends is viewable at `http://localhost:8025`. | SES | +| Database | Postgres 16 container on host port `55432` through pgx, connecting as the non-superuser `substratal` role so Row-Level Security applies | Aurora Serverless v2 through the Data API | +| Webhook delivery | In-process: the devserver nudges its own worker after each commit and sweeps every 2 seconds. Set `WEBHOOKS_ALLOW_PRIVATE=true` (the default in `.env.example`) to deliver to `http://localhost` receivers. | SQS nudge → worker Lambda, plus a 1-minute sweep | +| Scheduled jobs | The entitlement sweep runs in the devserver every 5 minutes; run any job on demand with `make job name=…` (`make jobs` lists them) | EventBridge Scheduler → `jobs` Lambda | +| JWT signing, secret encryption | A local RSA-2048 key, generated into `.dev/signing-key.pem` on first run, and a fixed development AES key (set `KMS_*_KEY_ID` to use KMS instead, including LocalStack's) | AWS KMS | +| Secrets | `.env` | Secrets Manager (`APP_SECRET_ARN`) | +| Email | Mailpit. Every email the API sends is viewable at `http://localhost:58025`. | SES | | Stripe | Stripe test mode. `make stripe-listen` runs `stripe listen --forward-to localhost:8080/internal/stripe/webhook`. | Stripe live mode | -| Static assets | LocalStack S3 | S3 + CloudFront | +| Audit archive | `.dev/audit-archive/` | S3, Glacier Deep Archive storage class | -Configuration is entirely environment variables, documented in `.env.example`. `APP_ENV=local` selects the pgx backend and the local email sender. `AWS_ENDPOINT_URL=http://localhost:4566` points every AWS SDK client at LocalStack without any code changes. Nothing in handler or business-logic code branches on the environment. Only the wiring in `internal/platform` does. +Configuration is entirely environment variables, documented in `.env.example`. `DATABASE_BACKEND`, `EMAIL_BACKEND`, and the `KMS_*` ids select each implementation; `AWS_ENDPOINT_URL=http://localhost:4566` points any AWS SDK client at LocalStack without code changes. Nothing in handler or business-logic code branches on the environment. Only the wiring in `internal/platform/wire` does. ### The two database backends [DEPLOYMENT.md → Local development](DEPLOYMENT.md#local-development) explains why the Data API can't run locally. Here's how the code handles it: -- Every database call goes through repository interfaces in `internal/store`. Handlers and business logic never import `pgx` or the Data API client. -- SQL is written once, as named-parameter queries (`:user_id`) kept next to each repository. The pgx backend rewrites them to `$1` positional form, and the Data API backend passes them as `SqlParameter`s. Same SQL text in both places. -- Transactions use one interface: `pgx.Tx` locally, and the Data API's `BeginTransaction`/`CommitTransaction` with a `transactionId` in AWS. Audit Events written in the same transaction as the change they record ([Transaction boundaries](https://adron.github.io/substratalapps.com/non-functional-requirements/#transaction-boundaries)) work identically on both. -- Row-Level Security relies on a per-transaction `set_config('app.tenant_id', …, true)`. Both backends issue it as the first statement of every transaction, through shared code. -- Known Data API differences are handled in the `dataapi` backend and covered by its own unit tests: type mapping (timestamps, JSON, arrays, numerics come back as typed fields), the 1 MB response limit (which is why list endpoints are always paginated anyway), and statement timeouts. Integration tests cover the pgx path in CI. The production smoke test is the check that the Data API path works end to end. +- Every database call goes through one small interface in `internal/db` (`Tx`, `Query`, `Exec`). Handlers never import `pgx` or the Data API client. +- SQL is written once, with named parameters (`:user_id`). The pgx driver rewrites them to `$1` positional form; the Data API driver passes them as `SqlParameter`s. Parameters are only ever scalars; arrays and JSON travel as text with explicit casts (`::text[]`, `::jsonb`), because the Data API has no array parameters. +- Every query that returns rows selects **one JSON column built by Postgres** (`to_jsonb(t)`, `jsonb_build_object(…)`), so both drivers return plain JSON text and the Data API's per-type field mapping never comes into it. +- Every request is one transaction. Its first statement sets `app.current_test_mode`, which the test/live Row-Level Security policy reads, so isolation is enforced by the database on both backends. Audit Events and webhook outbox rows written with a change commit with it ([Transaction boundaries](https://adron.github.io/substratalapps.com/non-functional-requirements/#transaction-boundaries)). +- The Data API driver's parameter mapping and error translation have their own unit tests. Integration tests cover the pgx path; the production smoke test checks the Data API path end to end. ### Day-to-day commands @@ -259,18 +256,19 @@ Configuration is entirely environment variables, documented in `.env.example`. ` |---|---| | `make up` / `make down` | Start / stop the local services. `make down` keeps data. `make reset` wipes it and starts fresh. | | `make migrate` | Apply pending migrations locally. `make migration name=add_x` creates the next numbered SQL file. | -| `make seed` | Load local data: a `superadmin`, a sample Application, and the Quickstart's users and grants. Built from the API Reference examples, per [Testing strategy](https://adron.github.io/substratalapps.com/non-functional-requirements/#testing-strategy). | -| `make run` | The devserver, rebuilt and restarted on file changes. | -| `make gen` | Regenerate `internal/api/gen/` from `docs/openapi.yaml`. Run it after any spec change, and commit the result with it. | +| `make seed` | Bootstrap a local `superadmin` (the same `cmd/admin` command production uses). | +| `make run` | The devserver. | +| `make gen` | Regenerate `internal/api/gen/` and `internal/mcp/tools.json` from `docs/openapi.yaml`. Run it after any spec change and commit the result; CI fails on stale generated code. | | `make test` | Unit tests. Fast, no containers. | -| `make test-integration` | Integration tests against the compose Postgres and LocalStack. | -| `make test-contract` | Contract tests: every devserver response validated against `docs/openapi.yaml`. | +| `make test-integration` | Every test, including integration tests against the compose Postgres. | +| `make test-contract` | The same run: contract checks are built into the integration harness, which validates every response against `docs/openapi.yaml`. A cross-cutting error status an operation doesn't list (`401`, `429`, …) is accepted when its body is the standard error envelope, since Conventions defines those once for every endpoint. | | `make quickstart` | The Quickstart walkthrough as a script against the running devserver. | | `make check` | Everything `ci.yml` runs, in the same order. Run it before pushing to `dev`. | | `make build` | Lambda artifacts for every function (`linux/arm64`) into `dist/`. | | `make tf-plan` | `terraform plan` for production with your own read-only credentials. Optional, since the PR does it anyway. | | `make job name=…` | Run one scheduled job once, locally. | | `make stripe-listen` | Forward Stripe test-mode webhooks to the devserver. | +| `make stripe-catalog` | Create the Stripe Products and Prices by lookup key (idempotent; refuses live mode without `CONFIRM_LIVE=yes`). | ### A normal change, start to finish diff --git a/docs/changelog.md b/docs/changelog.md index 073e4f8..6df339c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,6 +12,18 @@ What's changed in this specification over time. Questions still awaiting a decis --- +## 2026-10-06 — Implementation-driven `openapi.yaml` fixes + +The first implementation's contract tests validate every response against `openapi.yaml`, and found places where the YAML disagreed with the prose (which is authoritative). Each is now fixed in the YAML; no behavior described on any page changed: + +- ApplicationWrite.slug: pattern used a negative lookahead `(?!platform$)` that RE2-based JSON Schema validators (Go, Rust) can't compile; now `^[a-z][a-z0-9-]{1,63}$`, with `platform` documented as reserved in the description (the server still rejects it). +- RoleCreate/Role.description: now nullable, matching Domain Model → Role ("string, nullable"); a Role created without one has none. +- ApplicationWrite/ApplicationSummary description and icon_url: now nullable, matching the Applications page's own POST response example (`"icon_url": null`); both are optional on create. +- GET /organizations/{id}/entitlements: declared the filters its prose already promised ("same filters as GET /v1/entitlements": application_id, status, order_id, ends_before, include_inactive). +- GET /tenants: declared owner_user_id and owner_organization_id, which Tenancy → GET /v1/tenants already listed for tenants.manage. + +Questions the build surfaced where the spec was silent or self-contradictory are recorded as [DECISIONS.md #32–#37](https://github.com/Adron/substratalapps.com/blob/main/DECISIONS.md#awaiting-a-decision). + ## 2026-10-06 — Domain Model and API Reference review A review of every Domain Model and API Reference page against each other, the database schema, and `openapi.yaml`. Every fix is stated on the page it affects. From d5a587238c12f6170081580e40acdb015126ebcd Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Tue, 6 Oct 2026 05:23:05 -0700 Subject: [PATCH 3/3] Truncate the request clock to microseconds (Postgres precision) On Linux, Go's nanosecond clock could round a fresh grant's starts_at up past the request's own now, resolving it as scheduled and skipping access.granted. macOS clocks tick in microseconds, which hid it locally. Co-Authored-By: Claude Opus 5.5 --- internal/core/call.go | 4 +++- internal/core/system.go | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/core/call.go b/internal/core/call.go index 6e37ea7..6601481 100644 --- a/internal/core/call.go +++ b/internal/core/call.go @@ -43,6 +43,8 @@ type Call struct { op opts q db.Querier p *Principal + // now is truncated to microseconds, Postgres's timestamp precision: a + // row stamped with it must compare equal, not a rounding step later. now time.Time raw []byte // request body, read once @@ -66,7 +68,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, o opts, fn func(c ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) defer cancel() c := &Call{ - s: s, ctx: ctx, r: r, op: o, now: s.Now().UTC(), + s: s, ctx: ctx, r: r, op: o, now: s.Now().UTC().Truncate(time.Microsecond), headers: http.Header{}, ip: clientIP(r), requestID: RequestID(r.Context()), } raw, err := httpx.ReadBody(r) diff --git a/internal/core/system.go b/internal/core/system.go index e83a367..bb52492 100644 --- a/internal/core/system.go +++ b/internal/core/system.go @@ -16,7 +16,7 @@ import ( // per mode, so every event and audit row carries the right mode. func (s *Server) system(ctx context.Context, testMode bool, settings db.Settings, fn func(c *Call) error) error { settings.TestMode = testMode - c := &Call{s: s, ctx: ctx, now: s.Now().UTC(), headers: map[string][]string{}, sysTestMode: testMode} + c := &Call{s: s, ctx: ctx, now: s.Now().UTC().Truncate(time.Microsecond), headers: map[string][]string{}, sysTestMode: testMode} err := s.DB.Tx(ctx, settings, func(q db.Querier) error { c.q = q return fn(c)