diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..2f6ecc9 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,117 @@ +# CodeRabbit review rules for Kaasu. Source of truth for review behaviour; AGENTS.md holds the project rules. +language: en-GB +early_access: false + +reviews: + profile: assertive + request_changes_workflow: false + high_level_summary: true + high_level_summary_placeholder: "@coderabbitai summary" + review_status: true + commit_status: true + collapse_walkthrough: false + poem: false + auto_review: + enabled: true + drafts: false + auto_incremental_review: true + auto_pause_after_reviewed_commits: 0 + base_branches: + - ".*" + + path_filters: + - "!**/*.golden" + - "!**/*.lock" + - "!**/package-lock.json" + - "!**/go.sum" + - "!**/*.png" + - "!**/*.webp" + + path_instructions: + - path: "internal/match/**" + instructions: >- + MONEY PATH. Only this package may confirm a payment. Check: amounts are int64 paise (never float), + an auto-confirm needs an exact amount AND an authentic source AND score >= 80 AND an order under the + account limit, late or closed orders go to review, and Process is idempotent. Flag any new code path + that can reach confirmed without these checks. Every behaviour change needs a test. + - path: "internal/states/**" + instructions: >- + MONEY PATH. The transition table is the only place order state changes. Check every new edge against the + table in the card, that each transition writes a ledger row in the same transaction, and that refunds are + the only release of a held amount. + - path: "internal/ledger/**" + instructions: >- + MONEY PATH. The ledger is append-only and hash-chained. Flag anything that updates or deletes rows, + changes the hash inputs, or stores raw alert text or full payer identifiers. + - path: "internal/ingest/**" + instructions: >- + SECURITY. Every request is signed over the body hash, timestamp and nonce; the window is 60 seconds; + replays get 409; every failure is the same 401 with no detail. Flag any path that accepts an unsigned + or stale request, and any use of == on secrets. + - path: "internal/parsers/**" + instructions: >- + MONEY PATH. Amounts are integer paise parsed from the digit string. Flag ParseFloat or any float + arithmetic on money, any parser that accepts a debit, and any fixture with real customer data. + - path: "internal/email/**" + instructions: >- + SECURITY. DKIM is verified on the raw bytes; the signing domain must be an allow-listed bank domain + (exact or subdomain); the From address must align. Flag any path that trusts a message without DKIM. + - path: "internal/secrets/**" + instructions: >- + SECURITY. Secrets are envelope-encrypted (XChaCha20-Poly1305); the master key never lives in the + database; API keys are stored as SHA-256 hashes and shown once. Flag plaintext storage or logging. + - path: "internal/adminauth/**" + instructions: >- + SECURITY. Sessions are stored as hashes, cookies are HttpOnly, SameSite=Strict and Secure outside local + http; TOTP codes are single-use; lockout and the same reply for unknown names must hold. Flag any change + that weakens these. + - path: "internal/web/**" + instructions: >- + CUSTOMER PAGES. No third-party scripts, no inline scripts, no 'failed' copy while an outcome is + unknown, and no Paisu on customer pages. Text comes from the i18n catalogue, never hard-coded. + - path: "i18n/**" + instructions: >- + Every key must exist in every language with the same placeholders. Machine drafts must stay listed in + i18n/REVIEW.md. Do not mark a translation reviewed. + - path: "**/*.md" + instructions: >- + Documentation. Flag em dashes (U+2014) and claims the code does not back up. Keep copy plain. + - path: "web/**" + instructions: >- + Website. No em dashes. No emoji used as icons. No tech-stack wording on the landing page; talk about impact. + - path: "**/*.go" + instructions: >- + Go: stdlib first. Wrap errors with context. No package-level mutable globals. Table-driven tests. Money is + int64 paise. Flag any network call in tests. + + tools: + golangci-lint: + enabled: false + gitleaks: + enabled: true + semgrep: + enabled: true + ast-grep: + enabled: false + + finishing_touches: + docstrings: + enabled: false + unit_tests: + enabled: false + +chat: + auto_reply: true + +knowledge_base: + opt_out: false + learnings: + scope: auto + code_guidelines: + enabled: true + filePatterns: + - "AGENTS.md" + - "CONTRIBUTING.md" + - "architecture.md" + - "design.md" + - "SECURITY.md" diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..77175bd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.agents +*.db +.kaasu-dev +bin +web +node_modules +secrets diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..c84402d --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,18 @@ +area:server: + - changed-files: + - any-glob-to-any-file: ["internal/**", "cmd/**"] +area:web: + - changed-files: + - any-glob-to-any-file: ["web/**", "internal/web/**"] +area:docs: + - changed-files: + - any-glob-to-any-file: ["**/*.md", "docs/**"] +area:security: + - changed-files: + - any-glob-to-any-file: ["internal/secrets/**", "internal/adminauth/**", "internal/ingest/**", "SECURITY.md"] +area:i18n: + - changed-files: + - any-glob-to-any-file: ["i18n/**"] +area:infra: + - changed-files: + - any-glob-to-any-file: [".github/**", "deploy/**", "Dockerfile", "compose.yaml", "install.sh"] diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index afd2d7b..3f109c5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -17,8 +17,8 @@ jobs: language: [javascript-typescript, actions] # Add `go`, `java-kotlin` and `swift` when those code trees land. steps: - - uses: actions/checkout@v4 - - uses: github/codeql-action/init@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: github/codeql-action/init@4bfa242aa49c5f7c6770b98670d354a7dfac05f8 # v3 with: { languages: '${{ matrix.language }}' } - - uses: github/codeql-action/analyze@v3 + - uses: github/codeql-action/analyze@4bfa242aa49c5f7c6770b98670d354a7dfac05f8 # v3 with: { category: '/language:${{ matrix.language }}' } diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..c272535 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,63 @@ +name: go +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +concurrency: + group: go-${{ github.ref }} + cancel-in-progress: true +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: { go-version-file: go.mod } + - name: gofmt + run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1) + - run: go vet ./... + - run: go run honnef.co/go/tools/cmd/staticcheck@2026.2.2 ./... + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: { go-version-file: go.mod } + - run: make test-cover + - run: | + go tool cover -func=coverage.out | tail -1 | sed 's/^/Total coverage: /' >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: { name: coverage, path: coverage.out, retention-days: 7 } + fuzz: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: { go-version-file: go.mod } + - run: scripts/fuzz.sh 30s + build: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - { goos: linux, goarch: amd64 } + - { goos: linux, goarch: arm64 } + - { goos: darwin, goarch: amd64 } + - { goos: darwin, goarch: arm64 } + - { goos: windows, goarch: amd64 } + - { goos: windows, goarch: arm64 } + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: { go-version-file: go.mod } + - run: go build -trimpath -ldflags "-s -w" -o kaasu-${{ matrix.goos }}-${{ matrix.goarch }} ./cmd/kaasu + env: { CGO_ENABLED: '0', GOOS: '${{ matrix.goos }}', GOARCH: '${{ matrix.goarch }}' } + repro: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: { go-version-file: go.mod } + - run: scripts/repro-check.sh diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..61861ce --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,15 @@ +name: labeler +on: + pull_request_target: + types: [opened, synchronize, reopened] +permissions: + contents: read + pull-requests: write +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + sync-labels: true diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..cd6be9c --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,15 @@ +name: pr-title +on: + pull_request: + types: [opened, edited, synchronize, reopened] +permissions: + contents: read +jobs: + title: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - run: scripts/check-pr-title_test.sh + - run: scripts/check-pr-title.sh "$PR_TITLE" + env: + PR_TITLE: ${{ github.event.pull_request.title }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8346143 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: release +on: + push: + tags: ['v*'] +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - { goos: linux, goarch: amd64 } + - { goos: linux, goarch: arm64 } + - { goos: darwin, goarch: amd64 } + - { goos: darwin, goarch: arm64 } + - { goos: windows, goarch: amd64 } + - { goos: windows, goarch: arm64 } + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: { go-version-file: go.mod } + - name: Build + env: + CGO_ENABLED: '0' + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + VERSION: ${{ github.ref_name }} + run: | + ext=""; [ "$GOOS" = windows ] && ext=".exe" + go build -trimpath -buildvcs=false -ldflags "-s -w -X main.version=$VERSION" -o "kaasu-$GOOS-$GOARCH$ext" ./cmd/kaasu + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: bin-${{ matrix.goos }}-${{ matrix.goarch }} + path: kaasu-* + publish: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write # keyless cosign signing + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: { path: dist, merge-multiple: true } + - uses: anchore/sbom-action@55dc4ee22412511ee8c3142cbea40418e6cec693 # v0.17.8 + with: { path: dist, format: spdx-json, output-file: dist/kaasu.spdx.json } + # The SBOM is listed in SHA256SUMS, and SHA256SUMS is signed, so the SBOM is authenticated too. + - name: Checksums + working-directory: dist + run: sha256sum kaasu-* kaasu.spdx.json > SHA256SUMS + - uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0 + - name: Sign + working-directory: dist + run: | + for f in kaasu-* SHA256SUMS; do cosign sign-blob --yes --output-signature "$f.sig" --output-certificate "$f.pem" "$f"; done + - uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191 # v2.0.8 + with: + draft: true # a person publishes the draft after checking it + generate_release_notes: true + files: dist/* diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..2f3f67d --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,22 @@ +name: security +on: + pull_request: + push: + branches: [main] + schedule: + - cron: '23 4 * * 1' +permissions: + contents: read +jobs: + govulncheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: { go-version-file: go.mod } + - run: go run golang.org/x/vuln/cmd/govulncheck@v1.2.0 ./... + pins: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: scripts/check-pins.sh diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index a8efdd5..820cc4f 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -16,12 +16,12 @@ jobs: runs-on: ubuntu-latest defaults: { run: { working-directory: web } } steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: { node-version: 24, cache: npm, cache-dependency-path: web/package-lock.json } - run: npm ci - run: npm test - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: { name: site, path: web/dist, retention-days: 7 } deploy: needs: test @@ -29,11 +29,11 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: { name: site, path: web/dist } - name: Deploy to Cloudflare - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/woocommerce.yml b/.github/workflows/woocommerce.yml new file mode 100644 index 0000000..bcc9d12 --- /dev/null +++ b/.github/workflows/woocommerce.yml @@ -0,0 +1,18 @@ +name: woocommerce +on: + pull_request: + paths: ['integrations/woocommerce/**'] + push: + branches: [main] + paths: ['integrations/woocommerce/**'] +permissions: + contents: read +jobs: + php: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2 + with: { php-version: '8.2' } + - run: find integrations/woocommerce -name '*.php' -print0 | xargs -0 -n1 php -l + - run: php integrations/woocommerce/tests/run.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2647e2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +bin/ +*.db +*.db-wal +*.db-shm +.agents/plans/ +coverage.out + +# Terraform local state and providers +**/.terraform/ +*.tfstate* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb262f4..d161020 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,3 +44,15 @@ Never report vulnerabilities in public issues. Use [GitHub Security Advisories]( ## Be kind Assume good intent, review the code not the person, and help newcomers. Maintainers may moderate or lock threads that aren't. + +## Labels and milestones + +Each PR gets an `area:*` label from its changed files (`.github/labeler.yml`). Milestones: `M1 demo`, `M2 alerts`, `M3 surfaces`, `M4 launch`. + +## Branch names + +Stacked work uses `feat/-`, `fix/...`, `chore/...`, `web/...`. Each PR's base is the branch below it in the stack. + +## PR titles + +Titles must follow Conventional Commits (`feat(scope): subject`). The `pr-title` check enforces this. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e5e6f9c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Build a static binary, then ship it on a distroless base that runs as a non-root user. +FROM golang:1.27 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags "-s -w" -o /out/kaasu ./cmd/kaasu +RUN mkdir /data-empty + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/kaasu /usr/local/bin/kaasu +ENV KAASU_DATABASE_PATH=/data/kaasu.db +# Created with the runtime user as owner before VOLUME, so a fresh volume is writable by uid 65532. +COPY --from=build --chown=65532:65532 /data-empty /data +VOLUME ["/data"] +EXPOSE 8080 8443 +USER 65532:65532 +HEALTHCHECK --interval=30s --timeout=5s CMD ["/usr/local/bin/kaasu", "healthcheck"] +ENTRYPOINT ["/usr/local/bin/kaasu"] +CMD ["serve"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..faf2c6d --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: dev test test-cover lint build fuzz +dev: + go run ./cmd/kaasu serve +test: + go test -race ./... +test-cover: + go test -race -coverprofile=coverage.out ./... + scripts/coverage-gate.sh +lint: + @test -z "$$(gofmt -l .)" || (gofmt -l . && exit 1) + go vet ./... + go run honnef.co/go/tools/cmd/staticcheck@2026.2.2 ./... +fuzz: + scripts/fuzz.sh 20s +build: + CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o bin/kaasu ./cmd/kaasu diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..5b323a9 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,32 @@ +# Privacy + +> **Draft: has not been reviewed by a lawyer.** This describes what the software does, not legal advice. + +## Who is responsible +- **Self-hosted:** you run Kaasu on your own machine. You decide what it stores and you are the data fiduciary under India's Digital Personal Data Protection Act, 2023 (DPDP). OpenSawce receives none of your data. +- **Managed hosting (planned):** OpenSawce acts as a processor for you. Processor terms, sub-processors and data location will be published before managed hosting launches. + +## What Kaasu stores +- Orders: amount in paise, reference you set, state, timestamps. +- Payer identifiers: masked (for example `ra***@okicici`). Full VPAs stay only in the evidence store. +- Raw bank alerts (evidence): the text as received, so a decision can be checked later. +- The ledger: hash-chained event records, which never hold raw alert text. +- Admin sign-in data: a hashed session id; the TOTP secret is encrypted. + +## Retention +You choose how long evidence is kept. Ledger rows are kept so the chain stays verifiable. + +## Your rights +You can see, export and delete the data your instance holds. Deleting ledger rows is not possible by design; the chain would break. + +## AI features (off by default) +AI provider integration is planned and is not part of this version: nothing is sent to any AI provider today. When it ships, it will be off by default, and account numbers and VPAs will be replaced (masked) before any text is sent. The provider's own terms will apply to what it receives. + +## Children +Kaasu is for businesses and is not directed at children. + +## Breaches +Report security problems as described in SECURITY.md. + +## Contact +Open an issue, or use a private security advisory for anything sensitive. diff --git a/SECURITY.md b/SECURITY.md index 99ff1d6..129f416 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,3 +34,14 @@ If you act in good faith, stay within this policy, and give us a reasonable time Kaasu is pre-release. Only `main` is supported until the first tagged release. See the [threat model](architecture.md#5-threat-model) for what we already defend against. + +## Verifying a release + +Each release ships binaries, `SHA256SUMS`, an SPDX SBOM and a Sigstore signature per file. To check one: + +```bash +sha256sum --ignore-missing -c SHA256SUMS # checks only the files you downloaded +cosign verify-blob --certificate kaasu-linux-amd64.pem --signature kaasu-linux-amd64.sig \ + --certificate-identity-regexp 'https://github.com/opensawce/kaasu/.github/workflows/release.yml@.*' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com kaasu-linux-amd64 +``` diff --git a/TRADEMARK.md b/TRADEMARK.md new file mode 100644 index 0000000..d98e281 --- /dev/null +++ b/TRADEMARK.md @@ -0,0 +1,11 @@ +# Trademark + +> **Draft: has not been reviewed by a lawyer.** + +Status: **not yet filed.** Do not describe "Kaasu" or "Paisu" as registered marks. + +## Using the name +- You may say your software is compatible with Kaasu or built on the Kaasu code, under the AGPL-3.0 licence. +- Do not present a fork or a product as the official Kaasu service or endorsed by OpenSawce. +- Do not use the logo or mascot in a way that suggests endorsement. +- Do not use either name for a payment product of your own without written permission. diff --git a/architecture.md b/architecture.md index dd297f8..4e5ca08 100644 --- a/architecture.md +++ b/architecture.md @@ -16,12 +16,83 @@ Diagrams are drawn in Eraser. Source: `assets/diagrams/*.eraser` · Images: `ass ### Payment flow ![Payment confirmation flow](assets/diagrams/payment-flow.png) + +**Amount pool rules (issue #7, #8 hardening).** Each order that is not queued holds one exact paise amount from its base to base+99 on its account. A slot is held until `expires_at + 10 min` (grace). Cancelled, rejected and expired orders keep their slot until the grace ends; only a refund frees it at once. A queued order has no payable link: its UPI link appears only when it is promoted, and promotion gives it a fresh window of the same length. `Service.Sweep` promotes queued orders once slots free. + +**Evidence and dedupe (issue #15).** Raw alerts are stored byte-for-byte with their SHA-256; the same body twice is one row. A parsed payment is keyed by its UTR; when the UTR is missing it is keyed by a fingerprint of account hint, amount and a 5-minute time bucket. A second source for a known payment adds an `alert_sources` row instead of a new alert. Stored payer data is masked (`ra***@okicici`); the ledger records alert facts but never the raw text. + +**Bank alert parsers (issue #16).** One pattern per bank (HDFC, SBI, ICICI, Axis, Kotak) reads credits only; debits, promos and anything unrecognised return `ErrUnknownFormat` and never confirm an order. Amounts are read as integer paise from the digit string (commas stripped, no floats). Times are read in IST and stored as UTC. Sender ids and email domains are allow-listed; a look-alike such as `hdfcbank.net.evil.io` matches nothing. Fixtures are synthetic and checked against golden files. + +**Signed ingest (issue #14).** Phones and mail sources reach the server only on the ingest listener (`/ingest/v1/...`), never on public or admin. Pairing uses a single-use token valid 10 minutes, stored as a hash, shown as a `kaasu://pair` link. Every alert request is signed with the device's Ed25519 key over this exact string: +``` +KAASU-INGEST-V1\n\n\n\n +``` +Headers: `X-Kaasu-Device`, `X-Kaasu-Timestamp`, `X-Kaasu-Nonce` (16 to 64 characters), `X-Kaasu-Signature` (base64). A request is accepted when its timestamp is within 60 seconds of server time, the signature verifies and the device is paired and not revoked. Nonces are stored per device; a repeat is `409 replay`. Every other failure is `401 unauthorized` with no detail. Raw evidence is stored as received, before parsing. + +**Matcher and policy (issue #18).** The matcher is the only code that confirms a payment automatically, and only from a verified alert (a test scans the repo to enforce this). The one exception is an owner decision in `internal/review`, which needs the owner role, a valid CSRF token and a fresh sign-in. An alert is compared with open orders that have the exact amount. One candidate: an order past its expiry is late and goes to review even before the expiry job runs; a cancelled or rejected order goes to review as `order_closed` and stays closed; an awaiting order is scored. The score is built from signals with weights exact 40, authentic 20, UTR 10, note 10, timing 10, payer 5, multiple sources 5. Automatic confirmation needs an exact amount, an authentic source (device or DKIM-verified email, never manual), a score of at least 80, and an account limit the order is under. Anything else goes to review with a reason. Two candidates are ambiguous and both go to review. An alert one to 99 paise above an open order's base goes to review as `amount_mismatch`. Processing the same alert twice changes nothing. + +**Email alerts (issue #17).** The owner's inbox is polled over IMAP with an app password read from the vault on each poll. A message counts as an alert only when its DKIM signature verifies on the raw bytes, the signing domain is an allow-listed bank domain (exact or real subdomain), and the From address is aligned with that domain. Anything else is rejected and logged as `email.rejected` with a reason, never with the body. A verified message is stored as email evidence and goes through the same parser and matcher as device alerts. Messages are deduplicated by their raw hash, so a re-polled message changes nothing. + +**Scheduler (issue #10).** Time-based work is rows in the `jobs` table, so a restart resumes pending work. Retries wait 10 s, 30 s, 2 min, 10 min, then 1 h, and a job is `dead` after 8 attempts. A job is claimed with a 5-minute lock in a short transaction, runs outside any transaction, and panics are recorded as failures. Recurring jobs are stored in `recurring`. Built-in jobs: `orders.expire` (every 5 s; expires an order exactly at `expires_at`), `pool.sweep` (every minute; promotes queued orders once grace has ended) and `nonces.purge` (every 5 minutes). + +**Signed webhooks (issue #11).** Every change that matters to a shop writes an outbox row in the same transaction as the change: `order.confirmed`, `order.review`, `order.rejected`, `order.expired`, `order.cancelled`, `order.refunded`, `order.disputed`. The payload carries the order id, state, amount, reference and account, never payer data. Delivery is at least once with header `X-Kaasu-Signature: t=,v1=` and `X-Kaasu-Event-Id`. Retries wait 10 s, 30 s, 2 min, 10 min, 1 h, 6 h and 12 h between eight attempts (seven waits), then the event is dead-lettered. Targets must be https and must not resolve to loopback, private or link-local addresses; the guard checks the address actually dialled, and redirects are refused. Receivers verify with `webhooks.Verify` and dedupe by `event_id`. + +**Refunds (issue #33).** Kaasu never moves money; a refund is a record of one the merchant made in their own bank app. A refund is allowed only on a confirmed or late-paid order, the total refunded can never exceed the amount paid (checked inside the write transaction, so concurrent refunds cannot overshoot), and a UTR can be recorded once. A partial refund leaves the order in its current state (confirmed or late_paid); the refund that returns the full amount moves it to refunded, which sends the `order.refunded` webhook and frees the slot. + +**Reconciliation (issue #32).** A bank statement is imported as CSV (per-bank mappings; generic fallback), with integer paise and a 10 MB and 100,000-row cap. Importing the same file twice stores it once. Reconciliation is read-only: confirmed orders are matched to credits by UTR from their auto-match alert, then by an unused credit of the exact amount (an order with a UTR may only fall back to a credit that carries no UTR). Each imported statement belongs to one receiving account and only matches that account's orders. Four buckets come out: matched, confirmed-but-missing (the owner can move these to disputed), credited-but-unmatched, and amount-mismatch (a UTR match whose credit differs from the confirmed order amount). + +**Admin sign-in (issue #12, partial).** Admin sign-in is TOTP (RFC 6238, SHA-1, 30 s, 6 digits, one step of skew, each code works once) with sessions stored only as hashes. Sessions last 12 hours absolute and 30 minutes idle. Five failures lock a name for five minutes; unknown names and wrong codes get the same reply. Sensitive actions need a code within the last five minutes (`RequireFresh`, `Reauth`). Staff are read-only (`RequireOwner`). Forms carry a CSRF token derived from the session. Passkeys are the intended primary method and are not built yet. + +**HTTP baseline (issue #13).** Every listener wears the same protections: a strict Content-Security-Policy (`default-src 'self'`, no inline script), `nosniff`, `no-referrer`, `frame-ancestors 'none'`, HSTS only over TLS, a 15-second request limit, a 64 KB body cap, and panic recovery that returns a plain 500 with no stack. Rate limiting is a token bucket per key; client identity ignores forwarded headers unless a trusted proxy is configured. + +**Logs and metrics (issue #42).** Every log line passes a redaction handler: VPAs keep two characters of the name, 12-digit UTRs and long account numbers keep their last four digits, API keys and bearer tokens are masked. Requests carry an `X-Request-Id`. `/metrics` (Prometheus text) is served on the admin listener only. + +**Simulator and outbound guard (issue #51, partial).** `kaasu sim --list` names the ten payment scenarios with the state each should end in. `sim.Send` signs an alert with a device key and posts it through the real ingest handler, so development runs production code. `sim.CheckOutbound` refuses any call that leaves the machine (localhost is allowed). + +**Design tokens (issue #22, partial).** `assets/tokens.json` is the single source for colour, status words, spacing, radius and touch size. `go run ./scripts/gen-tokens` writes `internal/web/static/tokens.css`. Tests check the generated file matches the JSON, the app and the marketing site agree on every shared colour, every status has an icon and a word, icons sit on the 24 px grid, and every status pair meets WCAG AA. + +**Languages (issue #28, partial).** The catalogue is `i18n/.json`, one flat file per language, embedded in the binary. Every language must have every English key with the same placeholders (a test enforces this). `i18n.T` falls back to English. Money renders with Indian grouping and Latin digits (`₹1,24,860.50`). `i18n/REVIEW.md` lists every string that still needs a native-speaker check; hi and ta are machine drafts, and te, kn, ml, mr, bn, gu, pa and or are English until translated. + +**Customer pay page (issue #23).** `/pay/{id}` shows the exact amount, one primary button that names the amount and opens the UPI link, the waiting steps, and "Already paid? Don't pay again." Copy comes only from the catalogue. Waiting states never say "failed". `/pay/{id}/status` returns the state alone; `pay.js` polls it every two seconds with no third-party code. The page has no inline script, no third-party request, and stays under 30 KB. Language comes from `?lang=` (kept in a cookie), then the browser, then the shop default. + +**Payment confirmed page (issue #24).** `/pay/{id}/done` shows the success screen only when the order is confirmed or late-paid; anything else redirects back to the pay page, and the URL can never make success appear. It shows the amount from the order (a `?amount=` query is ignored), a live "time since confirmed" counter, the UTR, time, reference and a six-character check code. The code is an HMAC of the order, the confirmation time and the amount, so the shopkeeper can compare it. `/pay/{id}/receipt` is the printable receipt (`?format=txt` for plain text). The tick animation respects reduced motion. Paisu never appears on customer pages. + +**Payment links and counter QR (issue #25, partial).** A payment link has a fixed amount and an optional expiry; each visit creates or reuses an order through the same orders service as the API, keyed by link, visitor and amount, so a refresh reuses the order. A counter link has no amount: the shop types it (`ParseCounterAmount`: at least ₹1, at most ₹1,00,000, integer paise). Disabled and expired links answer as gone. `WhatsAppURL` builds the share link, and `Standee` is the printable A6 sign that loads nothing from outside. + +**Checkout snippet (issue #26, partial).** `kaasu.js` (under 3 KB, no dependencies, no inline handlers) opens the pay page by redirect or in a popup, and reports status from the pay page only when the message comes from that page's origin. `examples/webhooks/vectors.json` holds one signed delivery; the Go receiver example verifies it, and the other languages must do the same. + +**Voice announcements (issue #21, partial).** Only a confirmed or late-paid payment is ever spoken (`voice.Announcement`, tested over every state). The amount is read from the catalogue, in whole rupees when the paise are zero. The voice language is chosen separately from the screen language. `voice.js` speaks through the browser and remembers the last 50 order ids in session storage, so a reconnect never repeats a payment. + +**Merchant dashboard, first slice (issue #30, partial).** Mounted on the admin listener only. Sign-in is the TOTP form; every other page needs a session. Today shows money received today (IST), and a source health list where a device counts as live if it was seen in the last 15 minutes. Fewer than two live sources shows a warning; none says nothing is sending. Orders show the masked payer; a test checks a full VPA never reaches an admin page. + +**Review queue (issue #31).** Held payments wait in `internal/review`. The queue shows the expected amount next to what the bank said, the score breakdown and reason, and the raw alert (escaped on display). Confirm moves the order to confirmed, or to late-paid if its window has passed; reject needs one reason from a fixed list. Both write the owner's name into the ledger. On the admin listener a decision needs the owner role, a valid CSRF token and a fresh sign-in (five minutes); staff get 403. A second decision on the same order is refused. + +**Paisu moods (issue #34, rules only).** `paisu.Choose` picks one of five moods for merchant screens, by priority: worried (broken ledger or dead webhook), asleep (no live source), needs-ok (a held payment), happy (only right after a confirmation), otherwise waiting. Customer pages never show Paisu. The artwork and the template partial are not in this change. + +**Accessibility checks (issue #29, partial).** Tests read the templates and stylesheets and fail on: a missing `lang` or title, other than one `h1` per page, images without `alt`, decorative icons not hidden from screen readers, status pills without their word, removed focus outlines, buttons smaller than 44 px, and animation without a reduced-motion override. The pay page now has its heading and a visible focus ring. + +**AI layer (issue #46, partial).** Off by default: the feature flag, the per-feature flag and the DPDP notice acknowledgement must all be set. Input is masked (VPAs, UTRs, long account numbers, e-mail) and refused if an identifier survives masking. The answer must match a JSON schema (no extra fields, enum and length limits); anything else is refused and recorded nowhere. A monthly rupee cap blocks calls once spent (zero blocks everything). Each accepted answer writes an `ai_usage` row and an `ai.suggestion` ledger row naming the model. The package may not import states, match, orders, refunds or review; a test checks the imports. + ### Order states Order state machine ### Data model ![Data model](assets/diagrams/data-model.png) +SQLite in WAL mode, STRICT tables, foreign keys on, one writer (`BEGIN IMMEDIATE`). Migrations are numbered files in `internal/store/migrations/`; never edit an applied one. + +| Table | Holds | Guarded by | +|---|---|---| +| `receiving_accounts` | UPI IDs the shop receives on | unique VPA | +| `orders` | one row per order, state, exact paise | unique idempotency key, state check | +| `amount_reservations` | the unique amount an order that is not queued holds | primary key (account, amount) | +| `devices`, `nonces` | paired phones and replay protection | unique (device, nonce) | +| `evidence`, `alerts` | raw alert bodies and parsed facts | unique raw hash, unique UTR | +| `matches` | alert to order decisions | one match per alert | +| `refunds` | refunds recorded against orders | foreign key to orders | +| `ledger` | append-only hash chain: `hash = SHA-256(prev_hash, seq, ts, kind, order, actor, payload)`; triggers block UPDATE and DELETE; `kaasu verify` reports the first broken row; the chain detects edited rows but not removal of the newest rows unless the latest hash is kept outside the database (see the backup work) | triggers plus the chain | +| `outbox` | webhook events waiting for delivery | unique event id | + ### Deployment | Option | Diagram | |---|---| @@ -72,7 +143,7 @@ Diagrams are drawn in Eraser. Source: `assets/diagrams/*.eraser` · Images: `ass | # | Threat | Defence | Test (#57) | |---|---|---|---| -| T1 | Fake payment: forged screenshot, hand-typed UTR, made-up alert | Only `internal/match` confirms, and only from a verified alert; manual UTR goes to review | Forged and unsigned alerts never confirm | +| T1 | Fake payment: forged screenshot, hand-typed UTR, made-up alert | Automatic confirmation happens only in `internal/match`, from a verified alert; manual UTR goes to review; an owner may confirm a held payment in `internal/review` (owner role, CSRF, fresh sign-in). The shopkeeper can also compare the six-character check code on the customer's success page or receipt with their own app: it is an HMAC (key kept in settings, stable across restarts) that binds the order, the confirmation time and the amount, so a forged screenshot fails the comparison | Forged and unsigned alerts never confirm | | T2 | Replayed or edited device alert | Ed25519 over body hash, timestamp and nonce; 60 s window; nonce store | Replay, clock skew, tampered body rejected | | T3 | Spoofed bank email | DKIM on the raw message; bank domain allow-list | Bad signature and look-alike domain ignored | | T4 | Wrong order matched (amount collision, late alert) | Unique paise per open order; ambiguous or late matches go to review | Collision and late-alert scenarios | diff --git a/assets/icons/alert.svg b/assets/icons/alert.svg new file mode 100644 index 0000000..74e4400 --- /dev/null +++ b/assets/icons/alert.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/check-circle.svg b/assets/icons/check-circle.svg new file mode 100644 index 0000000..da2391b --- /dev/null +++ b/assets/icons/check-circle.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/clock.svg b/assets/icons/clock.svg new file mode 100644 index 0000000..9eb54e4 --- /dev/null +++ b/assets/icons/clock.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/phone-off.svg b/assets/icons/phone-off.svg new file mode 100644 index 0000000..c870d2e --- /dev/null +++ b/assets/icons/phone-off.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/x-circle.svg b/assets/icons/x-circle.svg new file mode 100644 index 0000000..52b1de3 --- /dev/null +++ b/assets/icons/x-circle.svg @@ -0,0 +1 @@ + diff --git a/assets/tokens.json b/assets/tokens.json new file mode 100644 index 0000000..b879396 --- /dev/null +++ b/assets/tokens.json @@ -0,0 +1,18 @@ +{ + "color": { + "green": "#0F7B5F", "greenDeep": "#0A5C47", "mint": "#3FC59A", "gold": "#E8A33D", + "ink": "#14171A", "paper": "#F7F6F2", "amber": "#9A5410", "red": "#B42318", + "grey": "#5B6168", "body": "#3D4247", "line": "#E3E1DA", "greenTint": "#E3F2EC", "amberTint": "#FBEBD6", + "redTint": "#FDECEA", "white": "#FFFFFF" + }, + "status": { + "received": { "fg": "greenDeep", "bg": "greenTint", "icon": "check-circle", "word": "Money received" }, + "needsOk": { "fg": "amber", "bg": "amberTint", "icon": "alert", "word": "Needs your OK" }, + "waiting": { "fg": "grey", "bg": "line", "icon": "clock", "word": "Waiting" }, + "problem": { "fg": "red", "bg": "redTint", "icon": "x-circle", "word": "Problem" }, + "offline": { "fg": "grey", "bg": "line", "icon": "phone-off", "word": "Phone offline" } + }, + "space": [4, 8, 12, 16, 24, 32, 48], + "radius": { "sm": 10, "md": 14, "lg": 20 }, + "touch": 44 +} diff --git a/cmd/kaasu/main.go b/cmd/kaasu/main.go new file mode 100644 index 0000000..4da2551 --- /dev/null +++ b/cmd/kaasu/main.go @@ -0,0 +1,397 @@ +// Command kaasu is the single Kaasu binary: server, tools and simulator. +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "flag" + "fmt" + "io" + "log/slog" + "math" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/opensawce/kaasu/internal/accounts" + "github.com/opensawce/kaasu/internal/app" + "github.com/opensawce/kaasu/internal/clock" + "github.com/opensawce/kaasu/internal/config" + "github.com/opensawce/kaasu/internal/ledger" + "github.com/opensawce/kaasu/internal/obs" + "github.com/opensawce/kaasu/internal/secrets" + "github.com/opensawce/kaasu/internal/sim" + "github.com/opensawce/kaasu/internal/store" +) + +// version is set at build time with -ldflags "-X main.version=...". +var version = "dev" + +const usage = "usage: kaasu [flags]" + +func main() { + os.Exit(run(os.Args[1:], os.Getenv, os.Stdout, os.Stderr)) +} + +func run(args []string, getenv func(string) string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprintln(stderr, usage) + return 2 + } + switch args[0] { + case "version": + fmt.Fprintf(stdout, "kaasu %s\n", version) + return 0 + case "serve": + return serve(args[1:], getenv, stderr) + case "migrate": + return migrate(getenv, stdout, stderr) + case "keygen": + k := make([]byte, 32) + if _, err := rand.Read(k); err != nil { + fmt.Fprintln(stderr, "keygen:", err) + return 1 + } + fmt.Fprintln(stdout, base64.StdEncoding.EncodeToString(k)) + return 0 + case "rotate-key": + return rotateKey(getenv, stdout, stderr) + case "admin": + return admin(args[1:], getenv, stdout, stderr) + case "doctor": + return doctor(getenv, stdout, stderr) + case "healthcheck": + return healthcheck(getenv, stdout, stderr) + case "sim": + return simCmd(args[1:], stdout, stderr) + case "account": + return account(args[1:], getenv, stdout, stderr) + case "apikey": + return apikey(args[1:], getenv, stdout, stderr) + case "verify": + return verify(getenv, stdout, stderr) + case "backup", "pair": + fmt.Fprintf(stderr, "kaasu %s: not implemented yet\n", args[0]) + return 1 + default: + fmt.Fprintf(stderr, "unknown command %q\n%s\n", args[0], usage) + return 2 + } +} + +func migrate(getenv func(string) string, stdout, stderr io.Writer) int { + cfg, err := config.Load(getenv, getenv("KAASU_CONFIG")) + if err != nil { + fmt.Fprintln(stderr, "config:", err) + return 1 + } + st, err := store.Open(cfg.DatabasePath) + if err != nil { + fmt.Fprintln(stderr, "open database:", err) + return 1 + } + defer st.Close() + if err := st.Migrate(context.Background()); err != nil { + fmt.Fprintln(stderr, "migrate:", err) + return 1 + } + fmt.Fprintln(stdout, "schema up to date") + return 0 +} + +// rotateKey re-wraps every stored secret under KAASU_NEW_MASTER_KEY. After it succeeds, +// switch KAASU_MASTER_KEY to the new key; nothing changes if any secret fails to rewrap. +func rotateKey(getenv func(string) string, stdout, stderr io.Writer) int { + cfg, err := config.Load(getenv, getenv("KAASU_CONFIG")) + if err != nil { + fmt.Fprintln(stderr, "config:", err) + return 1 + } + cur, _, err := secrets.LoadMasterKey(getenv, keychainFor(getenv)) + if err != nil { + fmt.Fprintln(stderr, "current key:", err) + return 1 + } + next, err := secrets.ParseMasterKey(getenv("KAASU_NEW_MASTER_KEY")) + if err != nil { + fmt.Fprintln(stderr, "set KAASU_NEW_MASTER_KEY (make one with `kaasu keygen`):", err) + return 1 + } + st, err := store.Open(cfg.DatabasePath) + if err != nil { + fmt.Fprintln(stderr, "open database:", err) + return 1 + } + defer st.Close() + n, err := secrets.NewVault(st, cur, clock.Real{}).RotateAll(context.Background(), next) + if err != nil { + fmt.Fprintln(stderr, "rotate:", err) + return 1 + } + fmt.Fprintf(stdout, "rotated %d secrets; now point KAASU_MASTER_KEY at the new key\n", n) + return 0 +} + +// verify walks the ledger hash chain and exits non-zero at the first broken row. +func verify(getenv func(string) string, stdout, stderr io.Writer) int { + cfg, err := config.Load(getenv, getenv("KAASU_CONFIG")) + if err != nil { + fmt.Fprintln(stderr, "config:", err) + return 1 + } + st, err := store.Open(cfg.DatabasePath) + if err != nil { + fmt.Fprintln(stderr, "open database:", err) + return 1 + } + defer st.Close() + if err := st.Migrate(context.Background()); err != nil { + fmt.Fprintln(stderr, "migrate:", err) + return 1 + } + res, err := ledger.Verify(context.Background(), st.Read()) + if err != nil { + fmt.Fprintln(stderr, "verify:", err) + return 1 + } + fmt.Fprintln(stdout, res.Describe()) + if !res.OK { + return 1 + } + return 0 +} + +// openApp loads config and the master key, then builds the app (no listeners yet). +// openApp reports failures on stderr; quiet also silences the startup log lines. +func openApp(getenv func(string) string, stderr io.Writer, quiet bool) (*app.App, *slog.Logger, bool) { + cfg, err := config.Load(getenv, getenv("KAASU_CONFIG")) + if err != nil { + fmt.Fprintln(stderr, "config:", err) + return nil, nil, false + } + key, source, err := secrets.LoadMasterKey(getenv, keychainFor(getenv)) + if err != nil { + fmt.Fprintln(stderr, "master key:", err) + return nil, nil, false + } + var level slog.Level + _ = level.UnmarshalText([]byte(cfg.LogLevel)) + var logw io.Writer = stderr + if quiet { + logw = io.Discard + } + log := obs.NewLogger(logw, level) + log.Info("master key loaded", "source", source) + a, err := app.New(cfg, key, log) + if err != nil { + fmt.Fprintln(stderr, err) + return nil, nil, false + } + return a, log, true +} + +// doctor checks the things an install most often gets wrong. It exits 1 on any FAIL. +func doctor(getenv func(string) string, stdout, stderr io.Writer) int { + code := 0 + report := func(ok bool, name, detail string) { + word := "OK" + if !ok { + word = "FAIL" + code = 1 + } + fmt.Fprintf(stdout, "%-4s %-16s %s\n", word, name, detail) + } + cfg, err := config.Load(getenv, getenv("KAASU_CONFIG")) + report(err == nil, "config", errText(err, "loaded")) + if err != nil { + return code + } + _, src, err := secrets.LoadMasterKey(getenv, keychainFor(getenv)) + report(err == nil, "master key", errText(err, "from "+src)) + st, err := store.Open(cfg.DatabasePath) + report(err == nil, "database", errText(err, cfg.DatabasePath)) + if err != nil { + return code + } + defer st.Close() + pending, err := st.PendingMigrations(context.Background()) // read-only: doctor never changes the database + if err == nil && pending > 0 { + err = fmt.Errorf("%d migration(s) pending: start kaasu or run kaasu migrate", pending) + } + report(err == nil, "migrations", errText(err, "up to date")) + res, err := ledger.Verify(context.Background(), st.Read()) + report(err == nil && res.OK, "ledger", errText(err, res.Describe())) + return code +} + +// keychainFor is the macOS keychain, unless KAASU_DISABLE_KEYCHAIN=1 (containers, CI and tests). +func keychainFor(getenv func(string) string) func() (string, error) { + if getenv("KAASU_DISABLE_KEYCHAIN") == "1" { + return func() (string, error) { return "", os.ErrNotExist } + } + return secrets.MacKeychain +} + +func errText(err error, ok string) string { + if err != nil { + return err.Error() + } + return ok +} + +// healthcheck is what the container runs: the admin listener must answer /healthz. +func healthcheck(getenv func(string) string, stdout, stderr io.Writer) int { + cfg, err := config.Load(getenv, getenv("KAASU_CONFIG")) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + c := &http.Client{Timeout: 3 * time.Second} + resp, err := c.Get("http://" + cfg.AdminAddr + "/healthz") + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + fmt.Fprintln(stderr, "unhealthy:", resp.Status) + return 1 + } + fmt.Fprintln(stdout, "ok") + return 0 +} + +// simCmd lists the payment scenarios. Running one needs a paired device; that is wired with dev mode. +func simCmd(args []string, stdout, stderr io.Writer) int { + if len(args) == 1 && args[0] == "--list" { + for _, sc := range sim.All() { + fmt.Fprintf(stdout, "%-18s %s (ends %s)\n", sc.Name, sc.Description, sc.Expect) + } + return 0 + } + fmt.Fprintln(stderr, "usage: kaasu sim --list (running a scenario needs dev mode, not yet built)") + return 2 +} + +// admin init creates an admin with a fresh TOTP secret and prints the authenticator link once. +func admin(args []string, getenv func(string) string, stdout, stderr io.Writer) int { + const usage = "usage: kaasu admin init --name [--role owner|staff]" + if len(args) < 3 || args[0] != "init" || args[1] != "--name" || args[2] == "" || (len(args) != 3 && len(args) != 5) { + fmt.Fprintln(stderr, usage) + return 2 + } + name, role := args[2], "owner" + if len(args) == 5 { + if args[3] != "--role" || (args[4] != "owner" && args[4] != "staff") { + fmt.Fprintln(stderr, usage) + return 2 + } + role = args[4] + } + a, _, ok := openApp(getenv, stderr, true) + if !ok { + return 1 + } + defer a.Store.Close() + secret := make([]byte, 20) + if _, err := rand.Read(secret); err != nil { + fmt.Fprintln(stderr, "admin init:", err) + return 1 + } + ctx := ledger.WithActor(context.Background(), "cli") + if _, err := a.Admin.CreateAdmin(ctx, name, role, secret); err != nil { + fmt.Fprintln(stderr, "admin init:", err) + return 1 + } + uri, err := a.Admin.Enroll(ctx, name) + if err != nil { + fmt.Fprintln(stderr, "admin init:", err) + return 1 + } + fmt.Fprintf(stdout, "%s\nAdd this to an authenticator app now. It is shown once.\n", uri) + return 0 +} + +func account(args []string, getenv func(string) string, stdout, stderr io.Writer) int { + if len(args) == 0 || args[0] != "add" { + fmt.Fprintln(stderr, "usage: kaasu account add --vpa --bank --name [--limit-rupees N]") + return 2 + } + fs := flag.NewFlagSet("account add", flag.ContinueOnError) + fs.SetOutput(stderr) + vpa, bank, name := fs.String("vpa", "", "UPI ID"), fs.String("bank", "", "bank name"), fs.String("name", "", "display name") + limit := fs.Int64("limit-rupees", 0, "orders above this need your OK (0 = always ask)") + if err := fs.Parse(args[1:]); err != nil { + return 2 + } + a, _, ok := openApp(getenv, stderr, true) + if !ok { + return 1 + } + defer a.Store.Close() + if *limit < 0 || *limit > math.MaxInt64/100 { + fmt.Fprintln(stderr, "account add: --limit-rupees must be between 0 and", int64(math.MaxInt64/100)) + return 2 + } + acc, err := a.Accounts.Create(ledger.WithActor(context.Background(), "cli"), accounts.Input{VPA: *vpa, Bank: *bank, DisplayName: *name, AutoConfirmLimitPaise: *limit * 100}) + if err != nil { + fmt.Fprintln(stderr, "account add:", err) + return 1 + } + fmt.Fprintf(stdout, "added %s (%s)\n", acc.ID, acc.VPA) + return 0 +} + +func apikey(args []string, getenv func(string) string, stdout, stderr io.Writer) int { + if len(args) != 2 || args[0] != "new" { + fmt.Fprintln(stderr, "usage: kaasu apikey new