Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be8688de80 | |||
| 7a1c70ab9b |
@@ -86,9 +86,9 @@ const SPEC_JSON: &str = r##"{
|
||||
"slug": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"price_sats": { "type": "integer", "nullable": true },
|
||||
"price_currency": { "type": "string", "enum": ["SAT", "USD", "EUR"], "nullable": true },
|
||||
"price_value": { "type": "integer", "nullable": true },
|
||||
"price_sats": { "type": "integer", "nullable": true, "description": "Legacy SAT price. Still accepted on create for backward compatibility; new callers should send price_value + price_currency instead. Also returned in responses (derived from price_value when that path is used)." },
|
||||
"price_currency": { "type": "string", "enum": ["SAT", "USD", "EUR"], "nullable": true, "description": "Currency for price_value. Defaults to SAT." },
|
||||
"price_value": { "type": "integer", "nullable": true, "description": "Write field: price in the smallest unit of price_currency (sats for SAT, cents for USD/EUR). Send together with price_currency." },
|
||||
"active": { "type": "boolean" },
|
||||
"entitlements_catalog": {
|
||||
"type": "array",
|
||||
@@ -263,7 +263,7 @@ const SPEC_JSON: &str = r##"{
|
||||
"/v1/admin/licenses": {
|
||||
"get": {
|
||||
"summary": "List licenses",
|
||||
"description": "Scope required: `licenses:read`. Filter by status, product_slug, buyer_email, expiring soon, etc. via query params.",
|
||||
"description": "Scope required: `licenses:read`. Requires `product_id=<uuid>` (the product's UUID, not its slug); returns that product's licenses. Use `GET /v1/admin/licenses/search` to look up by buyer_email or invoice id.",
|
||||
"responses": { "200": { "description": "License list" } }
|
||||
},
|
||||
"post": {
|
||||
@@ -272,6 +272,13 @@ const SPEC_JSON: &str = r##"{
|
||||
"responses": { "200": { "description": "Issued license" } }
|
||||
}
|
||||
},
|
||||
"/v1/admin/licenses/search": {
|
||||
"get": {
|
||||
"summary": "Search licenses",
|
||||
"description": "Scope required: `licenses:read`. Look up licenses by `buyer_email`, `nostr_npub`, or `invoice_id` (whichever is supplied). With no filter, returns the 100 most-recent licenses. The `license_key` is never returned here (only on issue / recover).",
|
||||
"responses": { "200": { "description": "Matching licenses" } }
|
||||
}
|
||||
},
|
||||
"/v1/admin/licenses/{id}/revoke": {
|
||||
"post": {
|
||||
"summary": "Revoke a license",
|
||||
@@ -301,11 +308,6 @@ const SPEC_JSON: &str = r##"{
|
||||
}
|
||||
},
|
||||
"/v1/admin/products": {
|
||||
"get": {
|
||||
"summary": "List products",
|
||||
"description": "Scope required: `products:read`.",
|
||||
"responses": { "200": { "description": "Product list" } }
|
||||
},
|
||||
"post": {
|
||||
"summary": "Create a product",
|
||||
"description": "Scope required: `products:write`.",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Per-run scratch: live daemon DBs, logs, tokens, the symlink to the active run.
|
||||
# Disposable and may contain (worthless, post-teardown) fixture tokens.
|
||||
runs/
|
||||
@@ -0,0 +1,58 @@
|
||||
# Keysat onboarding harness
|
||||
|
||||
A disposable test rig that runs the global **`onboarding-tester`** agent against
|
||||
Keysat's developer SDK-integration journey, to find every place the *published
|
||||
docs* leave a newcomer stuck — and, on a clean run, to harvest a publishable
|
||||
"all it took was X, Y, Z" walkthrough.
|
||||
|
||||
The premise (from `~/Projects/standards/guides/onboarding-tester.md`): the agent
|
||||
is a fresh adopter who may use **only the published docs corpus**, never Keysat
|
||||
source. The harness builder (you) may read Keysat freely; the agent may not.
|
||||
|
||||
## What a run sets up
|
||||
|
||||
| Piece | What it is | Disposable via |
|
||||
|-------|------------|----------------|
|
||||
| Fixture daemon | a fresh `keysat` release binary on `127.0.0.1:<port>`, throwaway SQLite, fresh issuer keypair | `teardown.sh` |
|
||||
| Provisioning | a **merchant-onboard** scoped key minted with the fixture's master key (the operator's job, not the agent's) | — |
|
||||
| Docs corpus | `keysat-docs/` served over HTTP — the only how-to source the agent may read | `teardown.sh` |
|
||||
| Sandbox | a pristine Next.js/TS proof-of-work (`sandbox-template/`) copied to `/tmp/onboarding-tester/`, with one ungated "Pro export" to gate | `teardown.sh` |
|
||||
|
||||
The fixture's dummy `BTCPAY_URL` is never dialed in this path: **Stage 1 is
|
||||
license issuance + SDK integration, no payments.**
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./run.sh # boot + provision + serve docs + sandbox; writes AGENT_BRIEF.md
|
||||
# → feed runs/<id>/AGENT_BRIEF.md to the onboarding-tester agent
|
||||
./teardown.sh runs/<id> # stop daemon + docs server, remove sandbox
|
||||
./teardown.sh runs/<id> --purge # also delete the run dir
|
||||
```
|
||||
|
||||
Individual stages (`boot-fixture.sh`, `provision.sh`, `serve-docs.sh`,
|
||||
`make-sandbox.sh`) can be run on their own; each reads/writes
|
||||
`runs/<id>/state.env` and `runs/current` points at the active run.
|
||||
|
||||
## The loop
|
||||
|
||||
1. `./run.sh`, then run the `onboarding-tester` agent on the brief.
|
||||
2. Read `runs/<id>/reports/friction.md`. If `completed-clean`, harvest the
|
||||
walkthrough into `keysat-docs/agent.html`. Otherwise fix the highest-severity
|
||||
**doc** gaps (additively — document missing API/how-to; don't rewrite
|
||||
marketing copy), tear down, and re-run on a fresh fixture.
|
||||
3. Repeat until `completed-clean`.
|
||||
|
||||
## Stage 2 (gated, not built yet)
|
||||
|
||||
The buyer-pays-on-regtest path needs Keysat to ship `payment_providers:write` +
|
||||
the sandbox-mode daemon flag + the network gate (slices 3–5, in progress). It
|
||||
adds a Dockerized BTCPay regtest stack and grants the agent
|
||||
`merchant-onboard` + `payment_providers:write` so it can connect BTCPay
|
||||
(regtest) and drive a test buyer payment end to end. Connecting a *mainnet*
|
||||
wallet stays operator-only by design — that boundary is a feature, not a gap.
|
||||
|
||||
## Requirements
|
||||
|
||||
`cargo`, `node`/`npm`, `python3`, `curl`, `jq`, `openssl`. (Docker is only
|
||||
needed for Stage 2.)
|
||||
@@ -0,0 +1,62 @@
|
||||
# Stage 1 result — developer SDK-integration journey (no payments)
|
||||
|
||||
**Verdict: `completed-clean` on run 3.** A fresh adopter, using only the published
|
||||
docs, can stand up a product, issue a license under a non-master `merchant-onboard`
|
||||
key, integrate the TypeScript SDK into a Next.js app, and gate a feature so a valid
|
||||
license unlocks it and an absent/invalid one blocks it.
|
||||
|
||||
## Method
|
||||
|
||||
The harness (`./run.sh`) boots a disposable `keysat` fixture (fresh SQLite, fresh
|
||||
issuer keypair), mints a `merchant-onboard` scoped key with the fixture's master
|
||||
key, serves `keysat-docs/` as the published corpus, and materializes a pristine
|
||||
Next.js/TS proof-of-work (`sandbox-template/` → `/tmp/onboarding-tester/`). The
|
||||
global `onboarding-tester` agent then drives the journey **docs-only** — it never
|
||||
reads Keysat source. Corpus declared in-scope: the docs site, the daemon's
|
||||
`/v1/openapi.json`, and the npm `@keysat/licensing-client` README.
|
||||
|
||||
## Convergence
|
||||
|
||||
| Run | Verdict | Findings |
|
||||
|-----|---------|----------|
|
||||
| 1 | completed-with-stumbles (5) + 1 nit | SDK `verify()` shape wrong in integrate.html; product `price_value` vs `price_sats`; licenses filter param; `merchant-onboard` role undocumented; issuer-pubkey response shape; phantom `GET /v1/admin/products`. |
|
||||
| 2 | completed-with-stumbles (1) + 1 nit | "Find a license by email" pointed at the wrong endpoint; server-side key transport unstated. |
|
||||
| 3 | **completed-clean** | none. Walkthrough harvested to `agent.html`. |
|
||||
|
||||
Each finding was verified against Keysat source before the doc was changed (the
|
||||
agent can't read source; the harness builder can).
|
||||
|
||||
## Doc fixes shipped this loop
|
||||
|
||||
**`keysat-docs/` (static site — deploys independently):**
|
||||
- `integrate.html`: rewrote the verify/error examples (TS/Rust/Python) to the real
|
||||
v0.3 SDK — `verify()` throws/returns `Err` and yields `VerifyOk{payload,…}`; no
|
||||
`valid` boolean; entitlements at `payload.entitlements`; errors are `LicensingError`
|
||||
(`.code` in TS, `.kind` in Python; Rust `Error::BadSignature`/`BadFormat`). Replaced the
|
||||
result-fields table; added an offline-expiry note (`isExpiredAt`/`is_expired_at`; TS/Rust
|
||||
`verifyWithTime`) and server-side key-transport guidance.
|
||||
- `agent.html`: added the `merchant-onboard` role row; added "Create a product" and
|
||||
"Add a tier (policy)" workflows with the `price_value`/`price_sats` distinction;
|
||||
fixed the comp-license field name (`buyer_note` → `note`); pointed "Find a license
|
||||
by email" at `/v1/admin/licenses/search`; **added the publishable worked example**
|
||||
(the harvested walkthrough).
|
||||
- `wire-format.html`: corrected the `GET /v1/issuer/public-key` response shape.
|
||||
|
||||
**`licensing-service/src/api/openapi.rs` (served spec — ships with the next daemon
|
||||
release; the local fixture was rebuilt so the agent saw the fixes):**
|
||||
- `GET /v1/admin/licenses` description: requires `product_id=<uuid>`, not a slug.
|
||||
- Removed the phantom `GET /v1/admin/products` (only POST exists; list is the public
|
||||
`GET /v1/products`).
|
||||
- Added the `/v1/admin/licenses/search` path (was referenced but undefined).
|
||||
- Product schema: marked `price_value` as the write field, `price_sats` as derived.
|
||||
|
||||
## Reproduce
|
||||
|
||||
```sh
|
||||
./run.sh # prints the fixture URL, docs URL, merchant key, sandbox path
|
||||
# feed runs/<id>/AGENT_BRIEF.md to the onboarding-tester agent
|
||||
./teardown.sh runs/<id> # leaves nothing running
|
||||
```
|
||||
|
||||
Per-run logs and the three friction reports live under `runs/` (gitignored; the
|
||||
tokens there are worthless after teardown).
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# Boot a fresh, disposable Keysat daemon on a throwaway SQLite DB.
|
||||
# Creates a new run dir, writes its state file, points runs/current at it.
|
||||
# Echoes the run id on success.
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||
|
||||
require curl; require openssl; require node
|
||||
|
||||
# Build the daemon if the release binary is missing.
|
||||
if [[ ! -x "$DAEMON_BIN" ]]; then
|
||||
log "release binary missing; building (cargo build --release)…"
|
||||
( cd "$DAEMON_DIR" && cargo build --release >/dev/null ) || die "daemon build failed"
|
||||
fi
|
||||
|
||||
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||||
RUN_DIR="$RUNS_DIR/$RUN_ID"
|
||||
mkdir -p "$RUN_DIR"
|
||||
STATE="$RUN_DIR/state.env"
|
||||
: > "$STATE"
|
||||
|
||||
PORT="$(free_port)"
|
||||
MASTER="$(openssl rand -hex 32)"
|
||||
DB_DIR="$RUN_DIR/data"
|
||||
mkdir -p "$DB_DIR"
|
||||
|
||||
state_set "$STATE" RUN_ID "$RUN_ID"
|
||||
state_set "$STATE" RUN_DIR "$RUN_DIR"
|
||||
state_set "$STATE" PORT "$PORT"
|
||||
state_set "$STATE" BASE_URL "http://127.0.0.1:$PORT"
|
||||
state_set "$STATE" MASTER_KEY "$MASTER"
|
||||
|
||||
log "booting keysat fixture on 127.0.0.1:$PORT (db: $DB_DIR/keysat.db)"
|
||||
KEYSAT_BIND="127.0.0.1:$PORT" \
|
||||
KEYSAT_DB_PATH="$DB_DIR/keysat.db" \
|
||||
KEYSAT_ADMIN_API_KEY="$MASTER" \
|
||||
BTCPAY_URL="http://127.0.0.1:1" \
|
||||
KEYSAT_PUBLIC_URL="http://127.0.0.1:$PORT" \
|
||||
KEYSAT_OPERATOR_NAME="Onboarding Fixture" \
|
||||
nohup "$DAEMON_BIN" >"$RUN_DIR/daemon.log" 2>&1 &
|
||||
DAEMON_PID=$!
|
||||
state_set "$STATE" DAEMON_PID "$DAEMON_PID"
|
||||
|
||||
if ! wait_http "http://127.0.0.1:$PORT/healthz" 75; then
|
||||
warn "daemon did not become healthy; last log lines:"
|
||||
tail -20 "$RUN_DIR/daemon.log" >&2 || true
|
||||
kill "$DAEMON_PID" 2>/dev/null || true
|
||||
die "fixture failed to start"
|
||||
fi
|
||||
|
||||
ln -sfn "$RUN_DIR" "$CURRENT_LINK"
|
||||
ok "fixture healthy (pid $DAEMON_PID) at http://127.0.0.1:$PORT"
|
||||
echo "$RUN_ID"
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared config + helpers for the Keysat onboarding harness.
|
||||
# Sourced by the stage scripts; not run directly.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HARNESS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# onboarding-harness/ -> licensing-service-startos/ -> workspace root
|
||||
WORKSPACE="$(cd "$HARNESS_DIR/../.." && pwd)"
|
||||
DAEMON_DIR="$WORKSPACE/licensing-service-startos/licensing-service"
|
||||
DAEMON_BIN="$DAEMON_DIR/target/release/keysat"
|
||||
DOCS_DIR="$WORKSPACE/keysat-docs"
|
||||
TEMPLATE_DIR="$HARNESS_DIR/sandbox-template"
|
||||
|
||||
# Per-run scratch lives under runs/ (gitignored). The agent's sandbox copy
|
||||
# lives under /tmp/onboarding-tester/ per the onboarding-tester guide.
|
||||
RUNS_DIR="$HARNESS_DIR/runs"
|
||||
SANDBOX_BASE="/tmp/onboarding-tester"
|
||||
|
||||
# The active run's state file is pointed to by runs/current.
|
||||
CURRENT_LINK="$RUNS_DIR/current"
|
||||
|
||||
log() { printf '\033[1;34m[harness]\033[0m %s\n' "$*" >&2; }
|
||||
ok() { printf '\033[1;32m[ ok ]\033[0m %s\n' "$*" >&2; }
|
||||
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# state_set KEY VALUE — append/update a KEY=VALUE line in the run state file.
|
||||
# Not concurrency-safe (uses a fixed temp suffix); the stages call it serially.
|
||||
state_set() {
|
||||
local f="$1" k="$2" v="$3"
|
||||
touch "$f"
|
||||
# strip any existing line for this key, then append
|
||||
grep -v "^${k}=" "$f" > "$f.tmp" 2>/dev/null || true
|
||||
mv "$f.tmp" "$f"
|
||||
printf '%s=%s\n' "$k" "$v" >> "$f"
|
||||
}
|
||||
|
||||
# state_get FILE KEY
|
||||
state_get() { grep "^${2}=" "$1" | head -1 | cut -d= -f2-; }
|
||||
|
||||
# free_port — echo an unused TCP port on 127.0.0.1.
|
||||
free_port() {
|
||||
node -e 'const s=require("net").createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close();});'
|
||||
}
|
||||
|
||||
# wait_http URL TRIES — poll until URL returns 2xx/3xx, or die.
|
||||
wait_http() {
|
||||
local url="$1" tries="${2:-50}" i
|
||||
for i in $(seq 1 "$tries"); do
|
||||
if curl -fsS -o /dev/null "$url" 2>/dev/null; then return 0; fi
|
||||
sleep 0.2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
require() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; }
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Materialize a fresh, pristine proof-of-work app for the agent to integrate
|
||||
# into. Copies sandbox-template/ to /tmp/onboarding-tester/sandbox-<run>/ and
|
||||
# runs `npm install` so the app is known-good before the agent touches it.
|
||||
# The agent mutates ONLY this copy. Usage: make-sandbox.sh [RUN_DIR]
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||
require node; require npm
|
||||
|
||||
RUN_DIR="${1:-$(readlink "$CURRENT_LINK")}"
|
||||
[[ -d "$RUN_DIR" ]] || die "no run dir (boot a fixture first)"
|
||||
STATE="$RUN_DIR/state.env"
|
||||
RUN_ID="$(state_get "$STATE" RUN_ID)"
|
||||
|
||||
mkdir -p "$SANDBOX_BASE"
|
||||
SANDBOX="$SANDBOX_BASE/sandbox-$RUN_ID"
|
||||
rm -rf "$SANDBOX"
|
||||
log "copying pristine proof-of-work to $SANDBOX"
|
||||
# copy template without any stray build artifacts
|
||||
( cd "$TEMPLATE_DIR" && find . -type d \( -name node_modules -o -name .next \) -prune -o -type f -print \
|
||||
| while IFS= read -r f; do mkdir -p "$SANDBOX/$(dirname "$f")"; cp "$f" "$SANDBOX/$f"; done )
|
||||
|
||||
log "installing base app dependencies (npm install)…"
|
||||
( cd "$SANDBOX" && npm install --no-audit --no-fund >"$RUN_DIR/sandbox-npm.log" 2>&1 ) \
|
||||
|| { tail -20 "$RUN_DIR/sandbox-npm.log" >&2; die "sandbox npm install failed"; }
|
||||
|
||||
state_set "$STATE" SANDBOX "$SANDBOX"
|
||||
ok "pristine sandbox ready at $SANDBOX"
|
||||
echo "$SANDBOX"
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Provisioner step (the human operator's job, NOT the agent's): with the
|
||||
# fixture's master key, mint a merchant-onboard scoped key and capture the
|
||||
# issuer public key. Writes both into the run state file.
|
||||
# Usage: provision.sh [RUN_DIR] (defaults to runs/current)
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||
require curl; require jq
|
||||
|
||||
RUN_DIR="${1:-$(readlink "$CURRENT_LINK")}"
|
||||
[[ -d "$RUN_DIR" ]] || die "no run dir (boot a fixture first)"
|
||||
STATE="$RUN_DIR/state.env"
|
||||
BASE_URL="$(state_get "$STATE" BASE_URL)"
|
||||
MASTER="$(state_get "$STATE" MASTER_KEY)"
|
||||
|
||||
log "minting merchant-onboard scoped key via master key"
|
||||
RESP="$(curl -fsS -X POST "$BASE_URL/v1/admin/api-keys" \
|
||||
-H "Authorization: Bearer $MASTER" -H "Content-Type: application/json" \
|
||||
-d '{"label":"onboarding-agent","role":"merchant-onboard","scopes":[]}')" \
|
||||
|| die "key mint failed"
|
||||
TOKEN="$(echo "$RESP" | jq -r '.token')"
|
||||
[[ "$TOKEN" == ks_* ]] || die "unexpected mint response: $RESP"
|
||||
state_set "$STATE" MERCHANT_KEY "$TOKEN"
|
||||
|
||||
log "fetching issuer public key"
|
||||
PUBKEY_PEM="$(curl -fsS "$BASE_URL/v1/issuer/public-key" | jq -r '.public_key_pem')"
|
||||
[[ "$PUBKEY_PEM" == *"BEGIN PUBLIC KEY"* ]] || die "could not fetch issuer public key"
|
||||
printf '%s' "$PUBKEY_PEM" > "$RUN_DIR/issuer.pub"
|
||||
state_set "$STATE" ISSUER_PUBKEY_FILE "$RUN_DIR/issuer.pub"
|
||||
|
||||
ok "merchant-onboard key minted; issuer pubkey saved to $RUN_DIR/issuer.pub"
|
||||
echo "$TOKEN"
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot Stage 1 setup: boot fixture, provision the merchant-onboard key,
|
||||
# serve the docs corpus, materialize a pristine sandbox, then emit the agent
|
||||
# brief (AGENT_BRIEF.md) with the live URLs + credentials interpolated in.
|
||||
#
|
||||
# This script sets the stage; it does NOT run the agent (the orchestrator does
|
||||
# that with the global onboarding-tester agent, feeding it AGENT_BRIEF.md).
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||
|
||||
RUN_ID="$("$HARNESS_DIR/boot-fixture.sh")"
|
||||
RUN_DIR="$RUNS_DIR/$RUN_ID"
|
||||
STATE="$RUN_DIR/state.env"
|
||||
"$HARNESS_DIR/provision.sh" "$RUN_DIR" >/dev/null
|
||||
"$HARNESS_DIR/serve-docs.sh" "$RUN_DIR" >/dev/null
|
||||
"$HARNESS_DIR/make-sandbox.sh" "$RUN_DIR" >/dev/null
|
||||
|
||||
BASE_URL="$(state_get "$STATE" BASE_URL)"
|
||||
DOCS_URL="$(state_get "$STATE" DOCS_URL)"
|
||||
MERCHANT_KEY="$(state_get "$STATE" MERCHANT_KEY)"
|
||||
SANDBOX="$(state_get "$STATE" SANDBOX)"
|
||||
mkdir -p "$RUN_DIR/reports"
|
||||
|
||||
cat > "$RUN_DIR/AGENT_BRIEF.md" <<EOF
|
||||
# Onboarding-tester brief — Keysat SDK integration (Stage 1, no payments)
|
||||
|
||||
You are a **fresh adopter**, following your operating guide
|
||||
(\`~/Projects/standards/guides/onboarding-tester.md\`). Reach the goal below
|
||||
using **only the docs corpus**. Never read Keysat's server or SDK source to
|
||||
unblock yourself — if the docs don't get you there, that is a finding.
|
||||
|
||||
## Goal (checkable end-state)
|
||||
A developer with a Next.js/TypeScript app wants to sell it. Using a **scoped,
|
||||
non-master API key**, and the published docs only:
|
||||
|
||||
1. Define the product in Keysat's catalog.
|
||||
2. Add at least one tier/policy with an entitlement.
|
||||
3. Manually issue a license for that product/tier (a comp/dev license — no
|
||||
payment in this path).
|
||||
4. Integrate the TypeScript SDK into the proof-of-work app so the **Pro export**
|
||||
(\`GET /api/export\`) is gated: it returns the CSV only with a valid license.
|
||||
5. Verify the gate both ways: a **valid** license unlocks the export; **no**
|
||||
license and a **tampered/invalid** license are blocked (4xx, not the CSV).
|
||||
|
||||
Success = the gate demonstrably works both ways, reached from the docs alone.
|
||||
|
||||
## Docs corpus (the ONLY how-to sources you may consult)
|
||||
- The Keysat docs site, served at: **$DOCS_URL** (start at \`/integrate.html\`
|
||||
and \`/agent.html\`; the whole site is in-corpus).
|
||||
- The daemon's published OpenAPI spec: **$BASE_URL/v1/openapi.json**
|
||||
(unauthenticated; the docs explicitly point adopters here).
|
||||
- The npm package README for \`@keysat/licensing-client\` (\`npm view\`, or the
|
||||
package page). The SDK's published README is in-corpus.
|
||||
|
||||
**Out of corpus (do not open):** anything under the Keysat source tree
|
||||
(\`$WORKSPACE/licensing-service-startos\`, \`$WORKSPACE/licensing-client-*\`,
|
||||
migrations, tests, this harness). Reading any of it invalidates the run — say so
|
||||
if you do.
|
||||
|
||||
## Your sandbox (mutate ONLY this)
|
||||
\`$SANDBOX\` — a pristine copy of the "Acme Reports" app. Read its own
|
||||
\`README.md\` freely (it's your app). Deps are already installed. Run it with
|
||||
\`npm run dev\` (it serves on http://localhost:4311). Put all scratch under
|
||||
\`/tmp/onboarding-tester/\`.
|
||||
|
||||
## Credentials you were handed (a real adopter would get these from their operator)
|
||||
- Keysat server URL: **$BASE_URL**
|
||||
- Scoped API key (merchant-onboard role): **$MERCHANT_KEY**
|
||||
- (The issuer public key is fetchable per the docs — find how.)
|
||||
|
||||
You were NOT given the master admin key. If a step seems to require it, that is
|
||||
either an intended operator-only boundary (note it) or a doc gap (log it).
|
||||
|
||||
## Output
|
||||
Write your friction report to \`$RUN_DIR/reports/friction.md\` AND return it as
|
||||
your final message, exactly in the format from your guide (Verdict, Corpus &
|
||||
goal, Friction log most-severe-first, Path walked, Confidence). On a
|
||||
\`completed-clean\` verdict only, also emit the publishable walkthrough
|
||||
(secret-free, placeholders for URL/key). Record commands and doc locations as
|
||||
you go; do not work from memory.
|
||||
EOF
|
||||
|
||||
ok "Stage 1 staged. Run id: $RUN_ID"
|
||||
cat >&2 <<EOF
|
||||
|
||||
Fixture URL : $BASE_URL
|
||||
Docs corpus : $DOCS_URL
|
||||
Merchant key: $MERCHANT_KEY
|
||||
Sandbox : $SANDBOX
|
||||
Agent brief : $RUN_DIR/AGENT_BRIEF.md
|
||||
Reports dir : $RUN_DIR/reports/
|
||||
|
||||
Tear down with: $HARNESS_DIR/teardown.sh "$RUN_DIR"
|
||||
EOF
|
||||
echo "$RUN_ID"
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.next/
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
.env*.local
|
||||
@@ -0,0 +1,34 @@
|
||||
# Acme Reports — proof-of-work app
|
||||
|
||||
A deliberately tiny Next.js (App Router) + TypeScript app. It shows a small
|
||||
analytics table for free and offers a **Pro export** (CSV download) at
|
||||
`GET /api/export`.
|
||||
|
||||
**In its pristine state the Pro export is ungated** — anyone can download it.
|
||||
Your job, as the integrator, is to put it behind a Keysat license: only a
|
||||
holder of a valid license for this product should be able to export.
|
||||
|
||||
This README describes *your own app* — you may read it freely. It tells you
|
||||
nothing about how Keysat works; for that, use only the Keysat docs you were
|
||||
pointed at.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
npm install # already done for you in the sandbox
|
||||
npm run dev # starts on http://localhost:4311
|
||||
```
|
||||
|
||||
- `GET http://localhost:4311/` — the free report view.
|
||||
- `GET http://localhost:4311/api/export` — the Pro export (CSV). Currently free.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
After integration:
|
||||
|
||||
- `GET /api/export` returns the CSV **only** when a valid license is present.
|
||||
- With **no** license, or a **tampered/invalid** one, `/api/export` is blocked
|
||||
(a 4xx, not the CSV).
|
||||
|
||||
How the app learns the user's license key (env var, file, header) is your
|
||||
call — pick whatever the Keysat docs suggest and note it.
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ROWS, toCsv } from "@/lib/reports";
|
||||
|
||||
// The "Pro export" endpoint.
|
||||
//
|
||||
// PRISTINE STATE: this feature is currently FREE — anyone who hits it gets the
|
||||
// CSV. The goal of this proof-of-work is to gate it behind a valid Keysat
|
||||
// license so that only paying customers can export.
|
||||
//
|
||||
// (How you wire that in is up to the integrator following the Keysat docs.)
|
||||
|
||||
export async function GET() {
|
||||
const csv = toCsv(ROWS);
|
||||
return new Response(csv, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv",
|
||||
"Content-Disposition": 'attachment; filename="acme-report.csv"',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Acme Reports",
|
||||
description: "A tiny analytics tool with a paid Pro export.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body style={{ fontFamily: "system-ui, sans-serif", maxWidth: 640, margin: "3rem auto", padding: "0 1rem" }}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ROWS } from "@/lib/reports";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Acme Reports</h1>
|
||||
<p>Your signups and revenue by region. Viewing is free.</p>
|
||||
<table cellPadding={6} style={{ borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left">Region</th>
|
||||
<th align="right">Signups</th>
|
||||
<th align="right">Revenue (sats)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ROWS.map((r) => (
|
||||
<tr key={r.region}>
|
||||
<td>{r.region}</td>
|
||||
<td align="right">{r.signups}</td>
|
||||
<td align="right">{r.revenueSats.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 style={{ marginTop: "2rem" }}>Pro export</h2>
|
||||
<p>
|
||||
Download the full dataset as CSV. This is a paid feature:{" "}
|
||||
<a href="/api/export">/api/export</a>.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// The "data" behind Acme Reports. The free tier lets you view it on screen;
|
||||
// the paid "Pro export" feature lets you download it as CSV. That export is
|
||||
// the feature we want to gate behind a Keysat license.
|
||||
|
||||
export type Row = { region: string; signups: number; revenueSats: number };
|
||||
|
||||
export const ROWS: Row[] = [
|
||||
{ region: "North", signups: 412, revenueSats: 1_240_000 },
|
||||
{ region: "South", signups: 318, revenueSats: 980_500 },
|
||||
{ region: "East", signups: 521, revenueSats: 1_702_300 },
|
||||
{ region: "West", signups: 274, revenueSats: 731_900 },
|
||||
];
|
||||
|
||||
export function toCsv(rows: Row[]): string {
|
||||
const header = "region,signups,revenue_sats";
|
||||
const body = rows.map((r) => `${r.region},${r.signups},${r.revenueSats}`);
|
||||
return [header, ...body].join("\n") + "\n";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Keep the proof-of-work app deliberately boring: no experimental flags,
|
||||
// so any onboarding friction is attributable to Keysat, not to Next.js.
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "acme-reports",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Pristine proof-of-work app for the Keysat onboarding harness. A tiny Next.js report tool whose 'Pro export' feature is meant to be gated behind a Keysat license.",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4311",
|
||||
"build": "next build",
|
||||
"start": "next start -p 4311"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.1.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.10.7",
|
||||
"@types/react": "19.0.7",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve the keysat-docs/ site over HTTP as the "published docs corpus" the
|
||||
# agent is allowed to read. Writes the docs URL + server pid into state.
|
||||
# Usage: serve-docs.sh [RUN_DIR]
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||
|
||||
RUN_DIR="${1:-$(readlink "$CURRENT_LINK")}"
|
||||
[[ -d "$RUN_DIR" ]] || die "no run dir (boot a fixture first)"
|
||||
STATE="$RUN_DIR/state.env"
|
||||
[[ -d "$DOCS_DIR" ]] || die "keysat-docs not found at $DOCS_DIR"
|
||||
|
||||
PORT="$(free_port)"
|
||||
log "serving published docs corpus from $DOCS_DIR on 127.0.0.1:$PORT"
|
||||
# --directory avoids a `cd` subshell, so $! is the real python PID (not a
|
||||
# wrapper shell that would orphan the server on teardown). nohup survives the
|
||||
# SIGHUP when this script exits.
|
||||
nohup python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$DOCS_DIR" \
|
||||
>"$RUN_DIR/docs-server.log" 2>&1 &
|
||||
DOCS_PID=$!
|
||||
state_set "$STATE" DOCS_PID "$DOCS_PID"
|
||||
state_set "$STATE" DOCS_PORT "$PORT"
|
||||
state_set "$STATE" DOCS_URL "http://127.0.0.1:$PORT"
|
||||
|
||||
if ! wait_http "http://127.0.0.1:$PORT/" 25; then
|
||||
die "docs server failed to come up"
|
||||
fi
|
||||
ok "docs corpus served at http://127.0.0.1:$PORT (pid $DOCS_PID)"
|
||||
echo "http://127.0.0.1:$PORT"
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tear down a run: stop the daemon + docs server, remove the agent's sandbox
|
||||
# copy. Keeps the run dir (logs + reports) unless --purge is given.
|
||||
# Usage: teardown.sh [RUN_DIR] [--purge]
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||
|
||||
PURGE=0; RUN_DIR=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--purge) PURGE=1 ;;
|
||||
*) RUN_DIR="$a" ;;
|
||||
esac
|
||||
done
|
||||
RUN_DIR="${RUN_DIR:-$(readlink "$CURRENT_LINK" 2>/dev/null || true)}"
|
||||
[[ -n "$RUN_DIR" && -d "$RUN_DIR" ]] || { warn "no run dir to tear down"; exit 0; }
|
||||
STATE="$RUN_DIR/state.env"
|
||||
|
||||
for key in DAEMON_PID DOCS_PID; do
|
||||
pid="$(state_get "$STATE" "$key" 2>/dev/null || true)"
|
||||
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
log "stopped $key ($pid)"
|
||||
fi
|
||||
done
|
||||
|
||||
# Belt-and-suspenders: free the recorded ports in case a PID drifted.
|
||||
for portkey in PORT DOCS_PORT; do
|
||||
port="$(state_get "$STATE" "$portkey" 2>/dev/null || true)"
|
||||
[[ -z "$port" ]] && continue
|
||||
for lpid in $(lsof -ti "tcp:$port" -sTCP:LISTEN 2>/dev/null || true); do
|
||||
kill "$lpid" 2>/dev/null && log "freed port $port (pid $lpid)" || true
|
||||
done
|
||||
done
|
||||
|
||||
SANDBOX="$(state_get "$STATE" SANDBOX 2>/dev/null || true)"
|
||||
if [[ -n "$SANDBOX" && -d "$SANDBOX" ]]; then rm -rf "$SANDBOX"; log "removed sandbox $SANDBOX"; fi
|
||||
|
||||
if [[ "$PURGE" == 1 ]]; then
|
||||
rm -rf "$RUN_DIR"; log "purged run dir $RUN_DIR"
|
||||
[[ "$(readlink "$CURRENT_LINK" 2>/dev/null)" == "$RUN_DIR" ]] && rm -f "$CURRENT_LINK"
|
||||
fi
|
||||
ok "teardown complete"
|
||||
Reference in New Issue
Block a user