skills update; USER ACCOUNT system! Alpha!
This commit is contained in:
18
.agents/skills/claude-handoff/SKILL.md
Normal file
18
.agents/skills/claude-handoff/SKILL.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: claude-handoff
|
||||
description: Hand the current conversation off to a fresh background agent that picks up the work immediately.
|
||||
argument-hint: "What will the next session be used for?"
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Write a handoff summary of the current conversation so a fresh agent can continue the work. Instead of saving it, launch a background agent seeded with the summary as its prompt: `claude --bg --name "<descriptive name>" "<handoff summary>"`. It starts in the current working directory and returns immediately; the user manages it with `claude agents`.
|
||||
|
||||
Always pass `-n`/`--name` with a descriptive name (e.g. `--name "Fix login bug"`) — it sets the display name shown in the job list, session picker, and terminal title.
|
||||
|
||||
Include a "suggested skills" section in the summary, which suggests skills that the agent should invoke.
|
||||
|
||||
Do not duplicate content already captured in other artifacts (PRDs, plans, ADRs, issues, commits, diffs). Reference them by path or URL instead.
|
||||
|
||||
Redact any sensitive information, such as API keys, passwords, or personally identifiable information — the summary becomes the agent's prompt.
|
||||
|
||||
If the user passed arguments, treat them as a description of what the next session will focus on and tailor the summary accordingly.
|
||||
89
.agents/skills/code-review/SKILL.md
Normal file
89
.agents/skills/code-review/SKILL.md
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: code-review
|
||||
description: Review the changes since a fixed point (commit, branch, tag, or merge-base) along two axes — Standards (does the code follow this repo's documented coding standards?) and Spec (does the code match what the originating issue/PRD asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work-in-progress changes, or asks to "review since X".
|
||||
---
|
||||
|
||||
Two-axis review of the diff between `HEAD` and a fixed point the user supplies:
|
||||
|
||||
- **Standards** — does the code conform to this repo's documented coding standards?
|
||||
- **Spec** — does the code faithfully implement the originating issue / PRD / spec?
|
||||
|
||||
Both axes run as **parallel sub-agents** so they don't pollute each other's context, then this skill aggregates their findings.
|
||||
|
||||
The issue tracker should have been provided to you — run `/setup-matt-pocock-skills` if `docs/agents/issue-tracker.md` is missing.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Pin the fixed point
|
||||
|
||||
Whatever the user said is the fixed point — a commit SHA, branch name, tag, `main`, `HEAD~5`, etc. If they didn't specify one, ask for it.
|
||||
|
||||
Capture the diff command once: `git diff <fixed-point>...HEAD` (three-dot, so the comparison is against the merge-base). Also note the list of commits via `git log <fixed-point>..HEAD --oneline`.
|
||||
|
||||
Before going further, confirm the fixed point resolves (`git rev-parse <fixed-point>`) and the diff is non-empty. A bad ref or empty diff should fail here — not inside two parallel sub-agents.
|
||||
|
||||
### 2. Identify the spec source
|
||||
|
||||
Look for the originating spec, in this order:
|
||||
|
||||
1. Issue references in the commit messages (`#123`, `Closes #45`, GitLab `!67`, etc.) — fetch via the workflow in `docs/agents/issue-tracker.md`.
|
||||
2. A path the user passed as an argument.
|
||||
3. A PRD/spec file under `docs/`, `specs/`, or `.scratch/` matching the branch name or feature.
|
||||
4. If nothing is found, ask the user where the spec is. If they say there isn't one, the **Spec** sub-agent will skip and report "no spec available".
|
||||
|
||||
### 3. Identify the standards sources
|
||||
|
||||
Anything in the repo that documents how code should be written, such as `CODING_STANDARDS.md` or `CONTRIBUTING.md`.
|
||||
|
||||
On top of whatever the repo documents, the Standards axis always carries the **smell baseline** below — a fixed set of Fowler code smells (_Refactoring_, ch.3) that applies even when a repo documents nothing. Two rules bind it:
|
||||
|
||||
- **The repo overrides.** A documented repo standard always wins; where it endorses something the baseline would flag, suppress the smell.
|
||||
- **Always a judgement call.** Each smell is a labelled heuristic ("possible Feature Envy"), never a hard violation — and, like any standard here, skip anything tooling already enforces.
|
||||
|
||||
Each smell reads *what it is* → *how to fix*; match it against the diff:
|
||||
|
||||
- **Mysterious Name** — a function, variable, or type whose name doesn't reveal what it does or holds. → rename it; if no honest name comes, the design's murky.
|
||||
- **Duplicated Code** — the same logic shape appears in more than one hunk or file in the change. → extract the shared shape, call it from both.
|
||||
- **Feature Envy** — a method that reaches into another object's data more than its own. → move the method onto the data it envies.
|
||||
- **Data Clumps** — the same few fields or params keep travelling together (a type wanting to be born). → bundle them into one type, pass that.
|
||||
- **Primitive Obsession** — a primitive or string standing in for a domain concept that deserves its own type. → give the concept its own small type.
|
||||
- **Repeated Switches** — the same `switch`/`if`-cascade on the same type recurs across the change. → replace with polymorphism, or one map both sites share.
|
||||
- **Shotgun Surgery** — one logical change forces scattered edits across many files in the diff. → gather what changes together into one module.
|
||||
- **Divergent Change** — one file or module is edited for several unrelated reasons. → split so each module changes for one reason.
|
||||
- **Speculative Generality** — abstraction, parameters, or hooks added for needs the spec doesn't have. → delete it; inline back until a real need shows.
|
||||
- **Message Chains** — long `a.b().c().d()` navigation the caller shouldn't depend on. → hide the walk behind one method on the first object.
|
||||
- **Middle Man** — a class or function that mostly just delegates onward. → cut it, call the real target direct.
|
||||
- **Refused Bequest** — a subclass or implementer that ignores or overrides most of what it inherits. → drop the inheritance, use composition.
|
||||
|
||||
### 4. Spawn both sub-agents in parallel
|
||||
|
||||
Send a single message with two `Agent` tool calls. Use the `general-purpose` subagent for both.
|
||||
|
||||
**Standards sub-agent prompt** — include:
|
||||
|
||||
- The full diff command and commit list.
|
||||
- The list of standards-source files you found in step 3, **plus the smell baseline from step 3** pasted in full — the sub-agent has no other access to it.
|
||||
- The brief: "Report — per file/hunk where relevant — (a) every place the diff violates a documented standard: cite the standard (file + the rule); and (b) any baseline smell you spot: name it and quote the hunk. Distinguish hard violations from judgement calls — documented-standard breaches can be hard, but baseline smells are always judgement calls, and a documented repo standard overrides the baseline. Skip anything tooling enforces. Under 400 words."
|
||||
|
||||
**Spec sub-agent prompt** — include:
|
||||
|
||||
- The diff command and commit list.
|
||||
- The path or fetched contents of the spec.
|
||||
- The brief: "Report: (a) requirements the spec asked for that are missing or partial; (b) behaviour in the diff that wasn't asked for (scope creep); (c) requirements that look implemented but where the implementation looks wrong. Quote the spec line for each finding. Under 400 words."
|
||||
|
||||
If the spec is missing, skip the Spec sub-agent and note this in the final report.
|
||||
|
||||
### 5. Aggregate
|
||||
|
||||
Present the two reports under `## Standards` and `## Spec` headings, verbatim or lightly cleaned. Do **not** merge or rerank findings — the two axes are deliberately separate (see _Why two axes_).
|
||||
|
||||
End with a one-line summary: total findings per axis, and the worst issue _within each axis_ (if any). Don't pick a single winner across axes — that's the reranking the separation exists to prevent.
|
||||
|
||||
## Why two axes
|
||||
|
||||
A change can pass one axis and fail the other:
|
||||
|
||||
- Code that follows every standard but implements the wrong thing → **Standards pass, Spec fail.**
|
||||
- Code that does exactly what the issue asked but breaks the project's conventions → **Spec pass, Standards fail.**
|
||||
|
||||
Reporting them separately stops one axis from masking the other.
|
||||
12
.agents/skills/research/SKILL.md
Normal file
12
.agents/skills/research/SKILL.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: research
|
||||
description: Investigate a question against high-trust primary sources and capture the findings as a Markdown file in the repo. Use when the user wants a topic researched, docs or API facts gathered, or reading legwork delegated to a background agent.
|
||||
---
|
||||
|
||||
Spin up a **background agent** to do the research, so you keep working while it reads.
|
||||
|
||||
Its job:
|
||||
|
||||
1. Investigate the question against **primary sources** — official docs, source code, specs, first-party APIs — not a secondary write-up of them. Follow every claim back to the source that owns it.
|
||||
2. Write the findings to a single Markdown file, citing each claim's source.
|
||||
3. Save it where the repo already keeps such notes; match the existing convention, and if there is none, put it somewhere sensible and say where.
|
||||
101
.agents/skills/wayfinder/SKILL.md
Normal file
101
.agents/skills/wayfinder/SKILL.md
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
name: wayfinder
|
||||
description: Plan a huge chunk of work — more than one agent session can hold — as a shared map of investigation tickets on your issue tracker, and resolve them one at a time until the way to the goal is clear.
|
||||
---
|
||||
|
||||
A loose idea has arrived — too big for one agent session, and wrapped in fog: the route from here to a plan isn't visible yet. This skill charts it as a **shared map** on the repo's issue tracker, then works its tickets one at a time. The map is domain-agnostic — engineering work, course content, whatever fits the shape.
|
||||
|
||||
## Refer by name
|
||||
|
||||
Every map and ticket is an issue, so it has a **name** — its title. In everything the human reads — narration, the map's Decisions-so-far — refer to it by that name, never by a bare id, number, or slug. A wall of `#42, #43, #44` is illegible; names read at a glance. The id and URL don't vanish — a name wraps its link — but they ride *inside* the name, never stand in for it.
|
||||
|
||||
## The Map
|
||||
|
||||
The map is a single issue on this repo's issue tracker, labelled `wayfinder:map` — the canonical artifact. Its tickets are child issues of the map.
|
||||
|
||||
The map is an **index**, not a store. It lists the decisions made and points at the tickets that hold their detail; a decision lives in exactly one place — its ticket — so the map never restates it, only gists it and links.
|
||||
|
||||
**Where the map, its child tickets, blocking, and frontier queries physically live is tracker-specific.** Consult `docs/agents/issue-tracker.md` (the "Wayfinding operations" section) for how _this_ repo expresses them. If that doc is absent, default to the local-markdown tracker.
|
||||
|
||||
### The map body
|
||||
|
||||
The whole map at low resolution, loaded once per session. Open tickets are **not** listed — they are open child issues, found by query.
|
||||
|
||||
```markdown
|
||||
## Notes
|
||||
|
||||
<domain; skills every session should consult; standing preferences for this effort>
|
||||
|
||||
## Decisions so far
|
||||
|
||||
<!-- the index — one line per closed ticket: enough to judge relevance, then zoom the link for the detail the ticket holds -->
|
||||
|
||||
- [<closed ticket title>](link) — <one-line gist of the answer>
|
||||
|
||||
## Fog
|
||||
|
||||
<!-- see "Fog of war" for what belongs here -->
|
||||
```
|
||||
|
||||
### Tickets
|
||||
|
||||
Each ticket is a **child issue** of the map; the tracker's issue id is its identity. Its body is the question, sized to one 100K token agent session:
|
||||
|
||||
```markdown
|
||||
## Question
|
||||
|
||||
<the decision or investigation this ticket resolves>
|
||||
```
|
||||
|
||||
Each ticket carries a `wayfinder:<type>` label — one of `research`, `prototype`, `grilling`, `task` (see [Ticket Types](#ticket-types)).
|
||||
|
||||
A session **claims** a ticket by assigning it to the dev driving the map, **first**, before any work, so concurrent sessions skip it. That assignee _is_ the claim: an open, unassigned ticket is unclaimed.
|
||||
|
||||
Blocking uses the tracker's **native** dependency relationship — essential because it renders the frontier _visually_ in the tracker's own UI, so the human sees what's takeable without opening the map. Only a tracker that lacks native blocking falls back to a body convention. A ticket is **unblocked** when every ticket blocking it is closed; the **frontier** is the open, unblocked, unclaimed children — the edge of the known.
|
||||
|
||||
The answer isn't part of the body — it's recorded on resolution (see [Work through the map](#work-through-the-map)). Assets created while resolving a ticket are linked from the issue, not pasted in.
|
||||
|
||||
## Ticket Types
|
||||
|
||||
- **Research**: Reading documentation, third-party APIs, or local resources like knowledge bases. Creates a markdown summary as a linked asset. Use when knowledge outside the current working directory is required.
|
||||
- **Prototype**: Raise the fidelity of the discussion by making a cheap, rough, concrete artifact to react to — an outline, a rough take, a stub, or UI/logic code via the /prototype skill. Links the prototype as an asset. Use when "how should it look" or "how should it behave" is the key question.
|
||||
- **Grilling**: Conversation with the agent. Uses the /grilling and /domain-modeling skills. Asks one question at a time. The default case.
|
||||
- **Task**: Literal manual work that must be done before the discussion can move forward — nothing to decide, prototype, or research. Moving data, signing up for a service, provisioning access. The agent automates it where it can; otherwise it hands the human a precise checklist. Resolved when the work is done; the answer records what was done and any resulting facts (credentials location, new URLs, row counts) later tickets depend on.
|
||||
|
||||
## Fog of war
|
||||
|
||||
The map is _deliberately_ incomplete: don't chart what you can't yet see. Beyond the tickets lies fog — the dim view of decisions and investigations you can tell are coming but can't yet pin down, because they hang on questions still open. Resolving a ticket clears the fog ahead of it, graduating whatever's now specifiable into fresh tickets — one at a time, until the way to the goal is clear and no tickets remain.
|
||||
|
||||
The map's **Fog** section is where that dim view is written down: the suspected question, the area to revisit later, the risk you're deferring. Write as loosely or as fully as the view allows; it doubles as a signpost for collaborators reading where the effort is headed.
|
||||
|
||||
**Fog or ticket?** The test is whether you can state the question precisely now — _not_ whether you can answer it now.
|
||||
|
||||
- **Ticket when** the question is already sharp — even if it's blocked and you can't act on it yet.
|
||||
- **Fog when** you can't yet phrase it that sharply. Don't pre-slice fog into ticket-sized pieces: it's coarser than a ticket, and one patch may graduate into several tickets, or none, once the frontier reaches it.
|
||||
|
||||
Fog excludes only what's already decided (that's Decisions so far) and what's already a ticket.
|
||||
|
||||
## Invocation
|
||||
|
||||
Two modes. Either way, **never resolve more than one ticket per session.**
|
||||
|
||||
### Chart the map
|
||||
|
||||
User invokes with a loose idea.
|
||||
|
||||
1. Run a `/grilling` and `/domain-modeling` session to surface the open decisions.
|
||||
2. **Create the map** (label `wayfinder:map`): Notes filled in, Decisions-so-far empty, Fog sketched.
|
||||
3. **Create the tickets you can specify now** as child issues of the map — then wire blocking edges in a **second pass** (issues need ids before they can reference each other). Wiring sorts them into the frontier and the blocked; everything you can't yet specify stays in the Fog.
|
||||
4. Stop — charting the map is one session's work; do not also resolve tickets.
|
||||
|
||||
### Work through the map
|
||||
|
||||
User invokes with a map (URL or number). A ticket is **optional** — without one, you pick the next decision, not the user.
|
||||
|
||||
1. Load the **map** — the low-res view, not every ticket body.
|
||||
2. Choose the ticket. If the user named one, use it. Otherwise take the first frontier ticket in order. **Claim it**: assign it to yourself before any work.
|
||||
3. Resolve it — **zoom as needed**: fetch the full body of any related or closed ticket on demand; invoke the skills the `## Notes` block names. If in doubt, use `/grilling` and `/domain-modeling`.
|
||||
4. Record the resolution: post the answer as a **resolution comment**, **close** the issue, and **append a context pointer** to the map's Decisions-so-far.
|
||||
5. Add newly-surfaced tickets (create-then-wire); graduate any fog the answer has made specifiable, clearing each graduated patch from the Fog so it lives only as its new ticket. If the decision invalidates other parts of the map, update or delete those tickets.
|
||||
|
||||
The user may run unblocked tickets in parallel, so expect other sessions to be editing the tracker concurrently.
|
||||
45
.agents/skills/wizard/SKILL.md
Normal file
45
.agents/skills/wizard/SKILL.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: wizard
|
||||
description: Generate an interactive bash wizard that walks a human through a manual procedure — third-party setup, a one-off migration, an A→B state transition — opening URLs, capturing values, confirming each step, and writing .env files and GitHub Actions secrets.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Wizard
|
||||
|
||||
A **wizard** is a bash script that walks a human, step by step, through a manual procedure that's tedious to do by hand and tedious to re-explain to an AI every time. It opens each URL, says exactly what to click and copy, captures the values, writes them where they belong (`.env`, GitHub secrets), confirms at every stage, and shows how much is left. It might configure third-party services, run a one-off migration, or move the project from one state to another.
|
||||
|
||||
The delightful UX is already solved by [template.sh](template.sh) — progress with time-remaining, confirmation gates, cross-platform URL opening (including WSL), hidden secret entry, idempotent `.env` upserts, `gh secret`/`gh variable` writes, and a closing summary. **Your job is only to scope the procedure and author its stages.** The library above the `STAGES` marker is identical in every wizard; that consistency is the point — never hand-edit it.
|
||||
|
||||
A wizard is ephemeral by default — built for one run, saved to a scratch or `scripts/` path, deleted when the job's done. Commit it only when the user wants a repeatable setup path that should live in the repo.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Scope the procedure
|
||||
|
||||
Work out every manual step the human must take and every value that gets captured along the way. Read the repo first — don't ask cold:
|
||||
|
||||
- For setup: `.env`, `.env.example`, `.env.*`, `README`, `docker-compose*`, framework config, and `.github/workflows/*` (every `secrets.*` / `vars.*` reference is a value the wizard must produce).
|
||||
- For a migration or transition: the current state, the target state, and the irreversible actions between them.
|
||||
|
||||
Then show the user the ordered list of stages and the values each produces, and confirm — they may add, drop, or reorder.
|
||||
|
||||
**Done when:** every stage is named in order, and for each captured value you know (a) where the human gets it, (b) where it's written (`.env`, a GitHub secret, both, or nowhere — some stages are pure actions), and (c) whether it's secret (hidden entry) or public.
|
||||
|
||||
### 2. Map each stage's journey
|
||||
|
||||
For each stage, write the precise path a human follows: which URL to open, what to do there, where a value is shown, which variable it fills — e.g. "Dashboard → Developers → API keys → Reveal test key → copy". Where you don't actually know the current UI or the exact command, say so and ask the user or check the docs — never invent steps that may not exist.
|
||||
|
||||
**Done when:** every stage traces to concrete instructions a stranger could follow.
|
||||
|
||||
### 3. Author the wizard
|
||||
|
||||
Copy `template.sh` to the target path. Replace the example stage with one `stage` per step, in dependency order. Use the library helpers — `stage`, `say`/`step`, `open_url`, `ask`/`ask_secret`, `write_env`, `set_secret`/`set_var`, `pause`/`confirm` — and set `TOTAL_STAGES` and `TOTAL_MINUTES` to honest estimates (this drives the time-remaining display).
|
||||
|
||||
Hold the bar the template sets: open the URL before asking for its value, use `ask_secret` for anything secret, `write_env` every persisted value, `set_secret` only the values CI actually needs, and `confirm` before any irreversible action. Each `stage` clears the screen so only the current step is visible — keep a stage to one focused task so nothing the human needs scrolls away. Don't touch the library above the marker.
|
||||
|
||||
### 4. Verify and hand off
|
||||
|
||||
- `bash -n <script>`; run `shellcheck` if available.
|
||||
- `chmod +x <script>`.
|
||||
- Don't run it end-to-end yourself — it opens browsers and blocks on human input. Trace it statically instead: every value from step 1 is captured and lands where step 1 said, and every `set_secret` name exactly matches a `secrets.*` reference in CI.
|
||||
- Tell the user how to run it. If it's a repeatable setup path, commit it and link it from the README so the next person runs the script instead of asking an AI.
|
||||
@@ -86,6 +86,7 @@ services:
|
||||
--self-url "$${PUBLIC_TRACKER_URL}" \
|
||||
--relay-url "$${RELAY_URL}" \
|
||||
--stats-db /var/lib/meshnet/tracker-stats.sqlite \
|
||||
--accounts-db /var/lib/meshnet/accounts.sqlite \
|
||||
$${BILLING_ARGS} \
|
||||
$${TREASURY_ARGS} \
|
||||
$${PEER_ARGS}
|
||||
|
||||
@@ -65,6 +65,7 @@ services:
|
||||
--self-url "$${PUBLIC_TRACKER_URL}" \
|
||||
--relay-url "$${RELAY_URL}" \
|
||||
--stats-db /var/lib/meshnet/tracker-stats.sqlite \
|
||||
--accounts-db /var/lib/meshnet/accounts.sqlite \
|
||||
$${BILLING_ARGS} \
|
||||
$${TREASURY_ARGS} \
|
||||
$${PEER_ARGS}
|
||||
|
||||
299
packages/tracker/meshnet_tracker/accounts.py
Normal file
299
packages/tracker/meshnet_tracker/accounts.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""Tracker user accounts: registration, login, API-key management.
|
||||
|
||||
Accounts are identified by email address or wallet address (either works as
|
||||
the login identifier) and authenticated with a password (PBKDF2-SHA256).
|
||||
The first account ever registered becomes the admin; everyone after is a
|
||||
regular user.
|
||||
|
||||
Mutations are append-only events with unique ids — the same replication
|
||||
model as ``BillingLedger`` — so accounts and API keys converge across the
|
||||
tracker hive via gossip, and every dashboard can serve registration/login.
|
||||
Sessions are deliberately local to each tracker (bearer tokens in memory).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
DEFAULT_ACCOUNTS_DB_PATH = "accounts.sqlite"
|
||||
SESSION_TTL = 7 * 86400.0 # seconds
|
||||
PBKDF2_ITERATIONS = 200_000
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
API_KEY_PREFIX = "sk-mesh-"
|
||||
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def _hash_password(password: str, salt: str) -> str:
|
||||
return hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode(), bytes.fromhex(salt), PBKDF2_ITERATIONS
|
||||
).hex()
|
||||
|
||||
|
||||
class AccountStore:
|
||||
"""Thread-safe account/API-key store with SQLite persistence and event replication."""
|
||||
|
||||
def __init__(self, db_path: str | None = None) -> None:
|
||||
self._db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
self._accounts: dict[str, dict] = {} # account_id -> record
|
||||
self._by_identifier: dict[str, str] = {} # lowercased email/wallet -> account_id
|
||||
self._active_keys: dict[str, str] = {} # api_key -> account_id
|
||||
self._revoked_keys: set[str] = set()
|
||||
self._sessions: dict[str, dict] = {} # token -> {account_id, expires}
|
||||
self._seen_event_ids: set[str] = set()
|
||||
self._event_log: list[dict] = []
|
||||
self._dirty = False
|
||||
if self._db_path:
|
||||
self._init_db()
|
||||
self._load_from_db()
|
||||
|
||||
# ---- registration & login ----
|
||||
|
||||
def register(
|
||||
self,
|
||||
*,
|
||||
email: str | None = None,
|
||||
wallet: str | None = None,
|
||||
password: str,
|
||||
) -> dict:
|
||||
"""Create an account. The first account becomes the admin.
|
||||
|
||||
Raises ValueError with a user-facing message on invalid input.
|
||||
"""
|
||||
email = (email or "").strip().lower() or None
|
||||
wallet = (wallet or "").strip() or None
|
||||
if not email and not wallet:
|
||||
raise ValueError("provide an email or a wallet address")
|
||||
if email and not _EMAIL_RE.match(email):
|
||||
raise ValueError("invalid email address")
|
||||
if len(password or "") < MIN_PASSWORD_LENGTH:
|
||||
raise ValueError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
|
||||
with self._lock:
|
||||
for identifier in filter(None, (email, wallet)):
|
||||
if identifier.lower() in self._by_identifier:
|
||||
raise ValueError(f"an account for {identifier!r} already exists")
|
||||
salt = secrets.token_hex(16)
|
||||
event = {
|
||||
"id": f"register-{uuid.uuid4().hex}",
|
||||
"type": "register",
|
||||
"account_id": f"acct-{uuid.uuid4().hex}",
|
||||
"email": email,
|
||||
"wallet": wallet,
|
||||
"role": "admin" if not self._accounts else "user",
|
||||
"password_hash": _hash_password(password, salt),
|
||||
"salt": salt,
|
||||
"ts": time.time(),
|
||||
}
|
||||
self._apply_locked(event)
|
||||
return self._public_view(self._accounts[event["account_id"]])
|
||||
|
||||
def verify_login(self, identifier: str, password: str) -> dict | None:
|
||||
"""Return the public account view when credentials match, else None."""
|
||||
with self._lock:
|
||||
account_id = self._by_identifier.get((identifier or "").strip().lower())
|
||||
if account_id is None:
|
||||
return None
|
||||
record = self._accounts[account_id]
|
||||
if _hash_password(password or "", record["salt"]) != record["password_hash"]:
|
||||
return None
|
||||
return self._public_view(record)
|
||||
|
||||
# ---- sessions (local to this tracker) ----
|
||||
|
||||
def create_session(self, account_id: str) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
with self._lock:
|
||||
self._sessions[token] = {
|
||||
"account_id": account_id,
|
||||
"expires": time.time() + SESSION_TTL,
|
||||
}
|
||||
return token
|
||||
|
||||
def session_account(self, token: str | None) -> dict | None:
|
||||
if not token:
|
||||
return None
|
||||
with self._lock:
|
||||
session = self._sessions.get(token)
|
||||
if session is None:
|
||||
return None
|
||||
if session["expires"] < time.time():
|
||||
del self._sessions[token]
|
||||
return None
|
||||
record = self._accounts.get(session["account_id"])
|
||||
return self._public_view(record) if record else None
|
||||
|
||||
def destroy_session(self, token: str | None) -> None:
|
||||
if not token:
|
||||
return
|
||||
with self._lock:
|
||||
self._sessions.pop(token, None)
|
||||
|
||||
# ---- API keys ----
|
||||
|
||||
def create_api_key(self, account_id: str) -> str:
|
||||
api_key = API_KEY_PREFIX + secrets.token_hex(24)
|
||||
with self._lock:
|
||||
if account_id not in self._accounts:
|
||||
raise ValueError("unknown account")
|
||||
self._apply_locked({
|
||||
"id": f"createkey-{uuid.uuid4().hex}",
|
||||
"type": "create_key",
|
||||
"account_id": account_id,
|
||||
"api_key": api_key,
|
||||
"ts": time.time(),
|
||||
})
|
||||
return api_key
|
||||
|
||||
def revoke_api_key(self, account_id: str, api_key: str) -> bool:
|
||||
"""Revoke a key owned by ``account_id``. Returns False if not owned."""
|
||||
with self._lock:
|
||||
if self._active_keys.get(api_key) != account_id:
|
||||
return False
|
||||
self._apply_locked({
|
||||
"id": f"revokekey-{uuid.uuid4().hex}",
|
||||
"type": "revoke_key",
|
||||
"account_id": account_id,
|
||||
"api_key": api_key,
|
||||
"ts": time.time(),
|
||||
})
|
||||
return True
|
||||
|
||||
def keys_for(self, account_id: str) -> list[str]:
|
||||
with self._lock:
|
||||
return sorted(
|
||||
key for key, owner in self._active_keys.items() if owner == account_id
|
||||
)
|
||||
|
||||
def is_key_revoked(self, api_key: str) -> bool:
|
||||
with self._lock:
|
||||
return api_key in self._revoked_keys
|
||||
|
||||
# ---- views ----
|
||||
|
||||
def _public_view(self, record: dict) -> dict:
|
||||
return {
|
||||
"account_id": record["account_id"],
|
||||
"email": record.get("email"),
|
||||
"wallet": record.get("wallet"),
|
||||
"role": record["role"],
|
||||
"created_ts": record.get("ts", 0.0),
|
||||
}
|
||||
|
||||
def get_account(self, account_id: str) -> dict | None:
|
||||
with self._lock:
|
||||
record = self._accounts.get(account_id)
|
||||
return self._public_view(record) if record else None
|
||||
|
||||
def list_accounts(self) -> list[dict]:
|
||||
with self._lock:
|
||||
views = []
|
||||
for record in self._accounts.values():
|
||||
view = self._public_view(record)
|
||||
view["api_keys"] = sorted(
|
||||
key for key, owner in self._active_keys.items()
|
||||
if owner == record["account_id"]
|
||||
)
|
||||
views.append(view)
|
||||
return sorted(views, key=lambda v: v["created_ts"])
|
||||
|
||||
def account_count(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._accounts)
|
||||
|
||||
# ---- replication (same model as BillingLedger) ----
|
||||
|
||||
def events_since(self, index: int) -> tuple[list[dict], int]:
|
||||
with self._lock:
|
||||
return list(self._event_log[index:]), len(self._event_log)
|
||||
|
||||
def apply_events(self, events: list[dict]) -> int:
|
||||
applied = 0
|
||||
with self._lock:
|
||||
for event in events:
|
||||
event_id = event.get("id")
|
||||
if not event_id or event_id in self._seen_event_ids:
|
||||
continue
|
||||
self._apply_locked(event)
|
||||
applied += 1
|
||||
return applied
|
||||
|
||||
def _apply_locked(self, event: dict) -> None:
|
||||
etype = event.get("type")
|
||||
if etype == "register":
|
||||
account_id = event["account_id"]
|
||||
record = {
|
||||
"account_id": account_id,
|
||||
"email": event.get("email"),
|
||||
"wallet": event.get("wallet"),
|
||||
"role": event.get("role", "user"),
|
||||
"password_hash": event["password_hash"],
|
||||
"salt": event["salt"],
|
||||
"ts": float(event.get("ts", 0.0)),
|
||||
}
|
||||
self._accounts[account_id] = record
|
||||
for identifier in filter(None, (record["email"], record["wallet"])):
|
||||
self._by_identifier.setdefault(identifier.lower(), account_id)
|
||||
elif etype == "create_key":
|
||||
api_key = event["api_key"]
|
||||
if api_key not in self._revoked_keys:
|
||||
self._active_keys[api_key] = event["account_id"]
|
||||
elif etype == "revoke_key":
|
||||
api_key = event["api_key"]
|
||||
self._active_keys.pop(api_key, None)
|
||||
self._revoked_keys.add(api_key)
|
||||
else:
|
||||
return
|
||||
self._seen_event_ids.add(event["id"])
|
||||
self._event_log.append(event)
|
||||
self._dirty = True
|
||||
|
||||
# ---- persistence ----
|
||||
|
||||
def _init_db(self) -> None:
|
||||
con = sqlite3.connect(self._db_path) # type: ignore[arg-type]
|
||||
con.execute(
|
||||
"CREATE TABLE IF NOT EXISTS account_events "
|
||||
"(event_id TEXT PRIMARY KEY, payload TEXT NOT NULL, ts REAL NOT NULL)"
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
def _load_from_db(self) -> None:
|
||||
con = sqlite3.connect(self._db_path) # type: ignore[arg-type]
|
||||
rows = con.execute(
|
||||
"SELECT payload FROM account_events ORDER BY ts, event_id"
|
||||
).fetchall()
|
||||
con.close()
|
||||
with self._lock:
|
||||
for (payload,) in rows:
|
||||
try:
|
||||
event = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if event.get("id") not in self._seen_event_ids:
|
||||
self._apply_locked(event)
|
||||
self._dirty = False
|
||||
|
||||
def save_to_db(self) -> None:
|
||||
if not self._db_path:
|
||||
return
|
||||
with self._lock:
|
||||
if not self._dirty:
|
||||
return
|
||||
events = list(self._event_log)
|
||||
self._dirty = False
|
||||
con = sqlite3.connect(self._db_path) # type: ignore[arg-type]
|
||||
con.executemany(
|
||||
"INSERT OR IGNORE INTO account_events (event_id, payload, ts) VALUES (?, ?, ?)",
|
||||
[(e["id"], json.dumps(e), float(e.get("ts", 0.0))) for e in events],
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
@@ -20,6 +20,7 @@ import uuid
|
||||
|
||||
DEFAULT_PRICE_PER_1K_TOKENS = 0.02 # USDT
|
||||
DEFAULT_STARTING_CREDIT = 1.0 # USDT of Caller Credit for a new API key
|
||||
DEFAULT_BILLING_DB_PATH = "billing.sqlite"
|
||||
NODE_REVENUE_SHARE = 0.90 # nodes 90% / protocol cut 10% (ADR-0015)
|
||||
|
||||
|
||||
@@ -364,6 +365,34 @@ class BillingLedger:
|
||||
with self._lock:
|
||||
return self._node_pending.get(wallet, 0.0)
|
||||
|
||||
def usage_for(self, api_keys: list[str], *, recent_limit: int = 20) -> dict:
|
||||
"""Aggregate charge history for a set of API keys (dashboard view)."""
|
||||
keys = set(api_keys)
|
||||
requests = 0
|
||||
total_tokens = 0
|
||||
total_cost = 0.0
|
||||
recent: list[dict] = []
|
||||
with self._lock:
|
||||
for event in self._event_log:
|
||||
if event.get("type") != "charge" or event.get("api_key") not in keys:
|
||||
continue
|
||||
requests += 1
|
||||
total_tokens += int(event.get("total_tokens", 0))
|
||||
total_cost += float(event.get("cost", 0.0))
|
||||
recent.append({
|
||||
"api_key": event["api_key"],
|
||||
"model": event.get("model"),
|
||||
"total_tokens": event.get("total_tokens", 0),
|
||||
"cost": event.get("cost", 0.0),
|
||||
"ts": event.get("ts", 0.0),
|
||||
})
|
||||
return {
|
||||
"requests": requests,
|
||||
"total_tokens": total_tokens,
|
||||
"total_cost": total_cost,
|
||||
"recent": recent[-recent_limit:],
|
||||
}
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
with self._lock:
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,8 @@ import argparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
from .accounts import DEFAULT_ACCOUNTS_DB_PATH
|
||||
from .billing import DEFAULT_BILLING_DB_PATH
|
||||
from .server import TrackerServer, derive_relay_url_from_public_tracker_url
|
||||
|
||||
|
||||
@@ -40,9 +42,31 @@ def main() -> None:
|
||||
)
|
||||
common.add_argument(
|
||||
"--billing-db",
|
||||
default=None,
|
||||
default=DEFAULT_BILLING_DB_PATH,
|
||||
metavar="PATH",
|
||||
help="SQLite database path for the USDT billing ledger (enables billing; ADR-0015)",
|
||||
help=(
|
||||
"SQLite database path for the USDT billing ledger "
|
||||
f"(default: {DEFAULT_BILLING_DB_PATH}; ADR-0015)"
|
||||
),
|
||||
)
|
||||
common.add_argument(
|
||||
"--no-billing",
|
||||
action="store_true",
|
||||
help="Disable the USDT billing ledger",
|
||||
)
|
||||
common.add_argument(
|
||||
"--accounts-db",
|
||||
default=DEFAULT_ACCOUNTS_DB_PATH,
|
||||
metavar="PATH",
|
||||
help=(
|
||||
"SQLite database path for dashboard user accounts "
|
||||
f"(default: {DEFAULT_ACCOUNTS_DB_PATH})"
|
||||
),
|
||||
)
|
||||
common.add_argument(
|
||||
"--no-accounts",
|
||||
action="store_true",
|
||||
help="Disable dashboard user accounts (registration/login)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--solana-rpc-url",
|
||||
@@ -108,7 +132,9 @@ def main() -> None:
|
||||
cluster_self_url=args.self_url,
|
||||
stats_db=getattr(args, "stats_db", None),
|
||||
relay_url=relay_url,
|
||||
billing_db=getattr(args, "billing_db", None),
|
||||
enable_billing=not args.no_billing,
|
||||
billing_db=None if args.no_billing else args.billing_db,
|
||||
accounts_db=None if args.no_accounts else args.accounts_db,
|
||||
treasury=treasury,
|
||||
settle_period=args.settle_period,
|
||||
payout_threshold=args.payout_threshold,
|
||||
|
||||
@@ -30,6 +30,20 @@
|
||||
.empty { color:var(--dim); font-style:italic; }
|
||||
.pill { display:inline-block; padding:0 7px; border-radius:9px;
|
||||
border:1px solid var(--border); font-size:11px; }
|
||||
input, button { font:inherit; color:var(--fg); background:var(--bg);
|
||||
border:1px solid var(--border); border-radius:6px; padding:5px 8px; }
|
||||
input { width:100%; margin-bottom:6px; }
|
||||
button { cursor:pointer; color:var(--accent); }
|
||||
button:hover { border-color:var(--accent); }
|
||||
button.small { font-size:11px; padding:1px 7px; }
|
||||
.form-row { display:flex; gap:8px; }
|
||||
.form-row button { white-space:nowrap; }
|
||||
.error-msg { color:var(--bad); font-size:12px; min-height:16px; }
|
||||
.keybox { word-break:break-all; background:var(--bg); border:1px solid var(--border);
|
||||
border-radius:6px; padding:4px 8px; margin:4px 0; font-size:11px; }
|
||||
.tabs { display:flex; gap:10px; margin-bottom:8px; }
|
||||
.tabs a { color:var(--dim); cursor:pointer; }
|
||||
.tabs a.active { color:var(--accent); border-bottom:1px solid var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -39,6 +53,8 @@
|
||||
<span class="meta" id="refreshed"></span>
|
||||
</header>
|
||||
<main>
|
||||
<section id="account-section"><h2>Account</h2><div id="account">loading…</div></section>
|
||||
<section id="admin-section" style="display:none"><h2>All accounts (admin)</h2><div id="admin" class="empty"></div></section>
|
||||
<section><h2>Tracker hive</h2><div id="hive" class="empty">loading…</div></section>
|
||||
<section><h2>Nodes & coverage</h2><div id="nodes" class="empty">loading…</div></section>
|
||||
<section><h2>Client balances</h2><div id="clients" class="empty">loading…</div></section>
|
||||
@@ -193,6 +209,149 @@ function renderThroughput(stats) {
|
||||
$("throughput").innerHTML = table(["node", "model", "tps (1h)", "samples"], rows);
|
||||
}
|
||||
|
||||
// ---- account panel (registration / login / balance / usage / API keys) ----
|
||||
|
||||
let sessionToken = localStorage.getItem("meshnet_session") || null;
|
||||
let authTab = "login";
|
||||
|
||||
async function apiCall(path, method, body) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (sessionToken) headers["Authorization"] = "Bearer " + sessionToken;
|
||||
try {
|
||||
const r = await fetch(path, {
|
||||
method: method || "GET",
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
return { ok: r.ok, status: r.status, data };
|
||||
} catch {
|
||||
return { ok: false, status: 0, data: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function setSession(token) {
|
||||
sessionToken = token;
|
||||
if (token) localStorage.setItem("meshnet_session", token);
|
||||
else localStorage.removeItem("meshnet_session");
|
||||
}
|
||||
|
||||
function renderAuthForms(errorMsg) {
|
||||
const tab = (name, label) =>
|
||||
`<a class="${authTab === name ? "active" : ""}" onclick="switchAuthTab('${name}')">${label}</a>`;
|
||||
const identityFields =
|
||||
'<input id="auth-email" type="email" placeholder="email (or leave blank)">' +
|
||||
'<input id="auth-wallet" type="text" placeholder="wallet address (or leave blank)">';
|
||||
const form = authTab === "login"
|
||||
? '<input id="auth-identifier" type="text" placeholder="email or wallet address">' +
|
||||
'<input id="auth-password" type="password" placeholder="password">' +
|
||||
'<div class="form-row"><button onclick="doLogin()">Log in</button></div>'
|
||||
: identityFields +
|
||||
'<input id="auth-password" type="password" placeholder="password (min 8 chars)">' +
|
||||
'<div class="form-row"><button onclick="doRegister()">Create account</button></div>';
|
||||
$("account").innerHTML =
|
||||
`<div class="tabs">${tab("login", "Log in")}${tab("register", "Register")}</div>` +
|
||||
form + `<div class="error-msg">${errorMsg ? esc(errorMsg) : ""}</div>`;
|
||||
$("admin-section").style.display = "none";
|
||||
}
|
||||
|
||||
function switchAuthTab(name) { authTab = name; renderAuthForms(); }
|
||||
|
||||
async function doRegister() {
|
||||
const email = $("auth-email").value.trim();
|
||||
const wallet = $("auth-wallet").value.trim();
|
||||
const password = $("auth-password").value;
|
||||
const r = await apiCall("/v1/auth/register", "POST",
|
||||
{ email: email || null, wallet: wallet || null, password });
|
||||
if (!r.ok) { renderAuthForms(r.data.error || "registration failed"); return; }
|
||||
setSession(r.data.session_token);
|
||||
await renderAccountPanel();
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
const identifier = $("auth-identifier").value.trim();
|
||||
const password = $("auth-password").value;
|
||||
const r = await apiCall("/v1/auth/login", "POST", { identifier, password });
|
||||
if (!r.ok) { renderAuthForms(r.data.error || "login failed"); return; }
|
||||
setSession(r.data.session_token);
|
||||
await renderAccountPanel();
|
||||
}
|
||||
|
||||
async function doLogout() {
|
||||
await apiCall("/v1/auth/logout", "POST", {});
|
||||
setSession(null);
|
||||
renderAuthForms();
|
||||
}
|
||||
|
||||
async function createKey() {
|
||||
const r = await apiCall("/v1/account/keys", "POST", {});
|
||||
if (r.ok) await renderAccountPanel();
|
||||
}
|
||||
|
||||
async function revokeKey(key) {
|
||||
if (!confirm("Revoke this API key? Clients using it will stop working.")) return;
|
||||
await apiCall("/v1/account/keys/revoke", "POST", { api_key: key });
|
||||
await renderAccountPanel();
|
||||
}
|
||||
|
||||
async function renderAccountPanel() {
|
||||
const r = await apiCall("/v1/account");
|
||||
if (r.status === 404) { // accounts disabled on this tracker
|
||||
$("account-section").style.display = "none";
|
||||
$("admin-section").style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (!r.ok) { setSession(null); renderAuthForms(); return; }
|
||||
const { account, api_keys, balances, total_balance, usage } = r.data;
|
||||
const who = account.email || account.wallet || account.account_id;
|
||||
let html =
|
||||
`<div><b>${esc(who)}</b> <span class="pill">${esc(account.role)}</span> ` +
|
||||
`<button class="small" style="float:right" onclick="doLogout()">log out</button></div>` +
|
||||
`<div style="margin:6px 0">balance: <b class="${total_balance > 0 ? "ok" : "bad"}">` +
|
||||
`${usdt(total_balance)}</b> USDT · requests: <b>${usage.requests}</b> · ` +
|
||||
`tokens: <b>${usage.total_tokens}</b> · spent: <b>${usdt(usage.total_cost)}</b> USDT</div>`;
|
||||
html += '<div style="margin:6px 0"><b class="dim">API keys</b> ' +
|
||||
'<button class="small" onclick="createKey()">+ new key</button></div>';
|
||||
if (api_keys.length) {
|
||||
for (const key of api_keys) {
|
||||
html += `<div class="keybox">${esc(key)}` +
|
||||
` <span class="dim">(${usdt(balances[key] ?? 0)} USDT)</span>` +
|
||||
` <button class="small" onclick="revokeKey('${esc(key)}')">revoke</button></div>`;
|
||||
}
|
||||
} else {
|
||||
html += '<div class="empty">no active keys</div>';
|
||||
}
|
||||
if (usage.recent && usage.recent.length) {
|
||||
html += '<div style="margin-top:6px"><b class="dim">recent usage</b></div>' +
|
||||
table(["time", "model", "tokens", "cost"], usage.recent.slice().reverse().map(u => [
|
||||
new Date(u.ts * 1000).toLocaleTimeString(),
|
||||
esc(short(u.model || "?", 24)),
|
||||
`<span class="num">${esc(String(u.total_tokens))}</span>`,
|
||||
`<span class="num">${usdt(u.cost)}</span>`,
|
||||
]));
|
||||
}
|
||||
$("account").innerHTML = html;
|
||||
if (account.role === "admin") await renderAdminPanel();
|
||||
else $("admin-section").style.display = "none";
|
||||
}
|
||||
|
||||
async function renderAdminPanel() {
|
||||
const r = await apiCall("/v1/admin/accounts");
|
||||
if (!r.ok) { $("admin-section").style.display = "none"; return; }
|
||||
$("admin-section").style.display = "";
|
||||
const rows = (r.data.accounts || []).map(a => {
|
||||
const balance = Object.values(a.balances || {}).reduce((x, y) => x + y, 0);
|
||||
return [
|
||||
esc(short(a.email || a.wallet || a.account_id, 24)),
|
||||
`<span class="pill">${esc(a.role)}</span>`,
|
||||
String((a.api_keys || []).length),
|
||||
`<span class="num ${balance > 0 ? "ok" : "bad"}">${usdt(balance)}</span>`,
|
||||
new Date(a.created_ts * 1000).toLocaleDateString(),
|
||||
];
|
||||
});
|
||||
$("admin").innerHTML = table(["account", "role", "keys", "balance (USDT)", "created"], rows);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
$("self-url").textContent = location.host;
|
||||
const [raft, map, summary, settlements, wallets, stats] = await Promise.all([
|
||||
@@ -213,7 +372,9 @@ async function refresh() {
|
||||
$("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString();
|
||||
}
|
||||
refresh();
|
||||
renderAccountPanel();
|
||||
setInterval(refresh, 4000);
|
||||
setInterval(() => { if (sessionToken) renderAccountPanel(); }, 8000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -36,7 +36,8 @@ import uuid
|
||||
from importlib.resources import files
|
||||
from typing import Any
|
||||
|
||||
from .billing import BillingLedger
|
||||
from .accounts import DEFAULT_ACCOUNTS_DB_PATH, AccountStore
|
||||
from .billing import DEFAULT_BILLING_DB_PATH, BillingLedger
|
||||
from .gossip import NodeGossip
|
||||
from .raft import RaftNode
|
||||
|
||||
@@ -1261,6 +1262,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
gossip: "NodeGossip | None" = None,
|
||||
stats: "_StatsCollector | None" = None,
|
||||
billing: "BillingLedger | None" = None,
|
||||
accounts: "AccountStore | None" = None,
|
||||
benchmark_results_path: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(addr, handler)
|
||||
@@ -1275,6 +1277,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
self.gossip = gossip
|
||||
self.stats: _StatsCollector | None = stats
|
||||
self.billing: BillingLedger | None = billing
|
||||
self.accounts: AccountStore | None = accounts
|
||||
self.benchmark_results_path = benchmark_results_path or os.path.join(
|
||||
os.getcwd(), "benchmark_results.json"
|
||||
)
|
||||
@@ -1337,6 +1340,24 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
if self.path == "/v1/billing/forfeit":
|
||||
self._handle_billing_forfeit()
|
||||
return
|
||||
if self.path == "/v1/auth/register":
|
||||
self._handle_auth_register()
|
||||
return
|
||||
if self.path == "/v1/auth/login":
|
||||
self._handle_auth_login()
|
||||
return
|
||||
if self.path == "/v1/auth/logout":
|
||||
self._handle_auth_logout()
|
||||
return
|
||||
if self.path == "/v1/account/keys":
|
||||
self._handle_account_key_create()
|
||||
return
|
||||
if self.path == "/v1/account/keys/revoke":
|
||||
self._handle_account_key_revoke()
|
||||
return
|
||||
if self.path == "/v1/accounts/gossip":
|
||||
self._handle_accounts_gossip()
|
||||
return
|
||||
if self.path == "/v1/benchmark/hop-penalty":
|
||||
self._handle_benchmark_hop_penalty()
|
||||
return
|
||||
@@ -1382,6 +1403,10 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
self._handle_billing_summary()
|
||||
elif parsed.path == "/v1/billing/settlements":
|
||||
self._handle_billing_settlements()
|
||||
elif parsed.path == "/v1/account":
|
||||
self._handle_account_me()
|
||||
elif parsed.path == "/v1/admin/accounts":
|
||||
self._handle_admin_accounts()
|
||||
elif parsed.path == "/v1/benchmark/results":
|
||||
self._handle_benchmark_results()
|
||||
elif parsed.path == "/v1/registry/wallets":
|
||||
@@ -1649,6 +1674,13 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
"code": "missing_api_key",
|
||||
}})
|
||||
return
|
||||
if server.accounts is not None and server.accounts.is_key_revoked(api_key):
|
||||
self._send_json(401, {"error": {
|
||||
"message": "API key has been revoked",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}})
|
||||
return
|
||||
if not server.billing.has_funds(api_key):
|
||||
self._send_json(402, {"error": {
|
||||
"message": "insufficient balance: deposit USDT to continue",
|
||||
@@ -2431,6 +2463,165 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
)
|
||||
self._send_json(200, {"forfeited": event["amount"], **strike_state})
|
||||
|
||||
# ---- user accounts (registration, login, API keys) ----
|
||||
|
||||
def _session_account(self) -> dict | None:
|
||||
"""Resolve the session token in the Authorization header, or None."""
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if server.accounts is None:
|
||||
return None
|
||||
return server.accounts.session_account(_api_key_from_headers(self.headers))
|
||||
|
||||
def _require_accounts(self) -> "AccountStore | None":
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if server.accounts is None:
|
||||
self._send_json(404, {"error": "accounts are not enabled on this tracker"})
|
||||
return None
|
||||
return server.accounts
|
||||
|
||||
def _handle_auth_register(self):
|
||||
accounts = self._require_accounts()
|
||||
if accounts is None:
|
||||
return
|
||||
body = self._read_json_body()
|
||||
if body is None:
|
||||
return
|
||||
try:
|
||||
account = accounts.register(
|
||||
email=body.get("email"),
|
||||
wallet=body.get("wallet"),
|
||||
password=str(body.get("password") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
token = accounts.create_session(account["account_id"])
|
||||
api_key = accounts.create_api_key(account["account_id"])
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if server.billing is not None:
|
||||
server.billing.ensure_client(api_key) # grant Caller Credit up front
|
||||
print(
|
||||
f"[tracker] account registered: {account.get('email') or account.get('wallet')} "
|
||||
f"role={account['role']}", flush=True,
|
||||
)
|
||||
self._send_json(200, {"account": account, "session_token": token, "api_key": api_key})
|
||||
|
||||
def _handle_auth_login(self):
|
||||
accounts = self._require_accounts()
|
||||
if accounts is None:
|
||||
return
|
||||
body = self._read_json_body()
|
||||
if body is None:
|
||||
return
|
||||
identifier = body.get("identifier") or body.get("email") or body.get("wallet") or ""
|
||||
account = accounts.verify_login(str(identifier), str(body.get("password") or ""))
|
||||
if account is None:
|
||||
self._send_json(401, {"error": "invalid credentials"})
|
||||
return
|
||||
token = accounts.create_session(account["account_id"])
|
||||
self._send_json(200, {"account": account, "session_token": token})
|
||||
|
||||
def _handle_auth_logout(self):
|
||||
accounts = self._require_accounts()
|
||||
if accounts is None:
|
||||
return
|
||||
accounts.destroy_session(_api_key_from_headers(self.headers))
|
||||
self._send_json(200, {"ok": True})
|
||||
|
||||
def _handle_account_me(self):
|
||||
"""Balance, usage, and API keys for the logged-in account."""
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if self._require_accounts() is None:
|
||||
return
|
||||
account = self._session_account()
|
||||
if account is None:
|
||||
self._send_json(401, {"error": "login required"})
|
||||
return
|
||||
keys = server.accounts.keys_for(account["account_id"]) # type: ignore[union-attr]
|
||||
balances = {}
|
||||
usage: dict = {"requests": 0, "total_tokens": 0, "total_cost": 0.0, "recent": []}
|
||||
if server.billing is not None:
|
||||
balances = {key: server.billing.get_client_balance(key) for key in keys}
|
||||
usage = server.billing.usage_for(keys)
|
||||
self._send_json(200, {
|
||||
"account": account,
|
||||
"api_keys": keys,
|
||||
"balances": balances,
|
||||
"total_balance": sum(balances.values()),
|
||||
"usage": usage,
|
||||
})
|
||||
|
||||
def _handle_account_key_create(self):
|
||||
accounts = self._require_accounts()
|
||||
if accounts is None:
|
||||
return
|
||||
account = self._session_account()
|
||||
if account is None:
|
||||
self._send_json(401, {"error": "login required"})
|
||||
return
|
||||
api_key = accounts.create_api_key(account["account_id"])
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if server.billing is not None:
|
||||
server.billing.ensure_client(api_key)
|
||||
self._send_json(200, {"api_key": api_key})
|
||||
|
||||
def _handle_account_key_revoke(self):
|
||||
accounts = self._require_accounts()
|
||||
if accounts is None:
|
||||
return
|
||||
account = self._session_account()
|
||||
if account is None:
|
||||
self._send_json(401, {"error": "login required"})
|
||||
return
|
||||
body = self._read_json_body()
|
||||
if body is None:
|
||||
return
|
||||
api_key = body.get("api_key")
|
||||
if not api_key or not isinstance(api_key, str):
|
||||
self._send_json(400, {"error": "api_key is required"})
|
||||
return
|
||||
if not accounts.revoke_api_key(account["account_id"], api_key):
|
||||
self._send_json(404, {"error": "key not found on this account"})
|
||||
return
|
||||
self._send_json(200, {"revoked": api_key})
|
||||
|
||||
def _handle_admin_accounts(self):
|
||||
"""Admin-only: all accounts with their keys and balances."""
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
accounts = self._require_accounts()
|
||||
if accounts is None:
|
||||
return
|
||||
account = self._session_account()
|
||||
if account is None:
|
||||
self._send_json(401, {"error": "login required"})
|
||||
return
|
||||
if account["role"] != "admin":
|
||||
self._send_json(403, {"error": "admin role required"})
|
||||
return
|
||||
listing = accounts.list_accounts()
|
||||
if server.billing is not None:
|
||||
for entry in listing:
|
||||
entry["balances"] = {
|
||||
key: server.billing.get_client_balance(key)
|
||||
for key in entry.get("api_keys", [])
|
||||
}
|
||||
self._send_json(200, {"accounts": listing})
|
||||
|
||||
def _handle_accounts_gossip(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
body = self._read_json_body()
|
||||
if body is None:
|
||||
return
|
||||
if server.accounts is None:
|
||||
self._send_json(200, {"applied": 0})
|
||||
return
|
||||
events = body.get("events")
|
||||
if not isinstance(events, list):
|
||||
self._send_json(400, {"error": "events must be a list"})
|
||||
return
|
||||
applied = server.accounts.apply_events([e for e in events if isinstance(e, dict)])
|
||||
self._send_json(200, {"applied": applied})
|
||||
|
||||
def _handle_wallet_register(self):
|
||||
"""Bind the caller's client wallet pubkey to their API key (US-032).
|
||||
|
||||
@@ -2996,7 +3187,10 @@ class TrackerServer:
|
||||
stats_db: str | None = None,
|
||||
relay_url: str | None = None,
|
||||
billing: BillingLedger | None = None,
|
||||
enable_billing: bool = False,
|
||||
billing_db: str | None = None,
|
||||
accounts: AccountStore | None = None,
|
||||
accounts_db: str | None = None,
|
||||
benchmark_results_path: str | None = None,
|
||||
treasury: Any | None = None,
|
||||
deposit_poll_interval: float = 15.0,
|
||||
@@ -3028,15 +3222,27 @@ class TrackerServer:
|
||||
self._stats: _StatsCollector | None = _StatsCollector(db_path=stats_db) if stats_db else _StatsCollector()
|
||||
self._stats_stop = threading.Event()
|
||||
self._stats_thread: threading.Thread | None = None
|
||||
if billing is None and billing_db:
|
||||
if billing is None:
|
||||
db_path = billing_db
|
||||
if db_path is None and enable_billing:
|
||||
db_path = DEFAULT_BILLING_DB_PATH
|
||||
if db_path:
|
||||
preset_prices = {
|
||||
name: float(preset["price_per_1k_tokens"])
|
||||
for name, preset in self._model_presets.items()
|
||||
if isinstance(preset, dict) and "price_per_1k_tokens" in preset
|
||||
}
|
||||
billing = BillingLedger(db_path=billing_db, prices=preset_prices)
|
||||
billing = BillingLedger(db_path=db_path, prices=preset_prices)
|
||||
self._billing: BillingLedger | None = billing
|
||||
self._billing_gossip_cursor = 0
|
||||
if accounts is None:
|
||||
accounts_path = accounts_db
|
||||
if accounts_path is None and enable_billing:
|
||||
accounts_path = DEFAULT_ACCOUNTS_DB_PATH
|
||||
if accounts_path:
|
||||
accounts = AccountStore(db_path=accounts_path)
|
||||
self._accounts: AccountStore | None = accounts
|
||||
self._accounts_gossip_cursor = 0
|
||||
self._benchmark_results_path = benchmark_results_path
|
||||
self._treasury = treasury
|
||||
self._deposit_poll_interval = deposit_poll_interval
|
||||
@@ -3074,6 +3280,7 @@ class TrackerServer:
|
||||
relay_url=effective_relay_url,
|
||||
stats=self._stats,
|
||||
billing=self._billing,
|
||||
accounts=self._accounts,
|
||||
benchmark_results_path=self._benchmark_results_path,
|
||||
)
|
||||
self.port = self._server.server_address[1]
|
||||
@@ -3250,6 +3457,8 @@ class TrackerServer:
|
||||
self._stats.save_to_db()
|
||||
if self._billing is not None:
|
||||
self._billing.save_to_db()
|
||||
if self._accounts is not None:
|
||||
self._accounts.save_to_db()
|
||||
if self._stats is not None and self._cluster_peers:
|
||||
self_url = self._cluster_self_url or f"http://{self._host}:{self.port}"
|
||||
local_rpms = self._stats.get_local_rpms()
|
||||
@@ -3288,6 +3497,25 @@ class TrackerServer:
|
||||
# via event-id dedupe on the receiving side).
|
||||
if delivered_all:
|
||||
self._billing_gossip_cursor = cursor
|
||||
if self._accounts is not None and self._cluster_peers:
|
||||
events, cursor = self._accounts.events_since(self._accounts_gossip_cursor)
|
||||
if events:
|
||||
delivered_all = True
|
||||
body = json.dumps({"events": events}).encode()
|
||||
for peer in self._cluster_peers:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{peer}/v1/accounts/gossip",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=2.0) as r:
|
||||
r.read()
|
||||
except Exception:
|
||||
delivered_all = False
|
||||
if delivered_all:
|
||||
self._accounts_gossip_cursor = cursor
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._raft is not None:
|
||||
@@ -3304,6 +3532,8 @@ class TrackerServer:
|
||||
self._stats.save_to_db()
|
||||
if self._billing is not None:
|
||||
self._billing.save_to_db()
|
||||
if self._accounts is not None:
|
||||
self._accounts.save_to_db()
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
if self._thread is not None:
|
||||
|
||||
221
tests/test_accounts.py
Normal file
221
tests/test_accounts.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Dashboard user accounts: registration, login, roles, API keys, usage.
|
||||
|
||||
Unit tests for AccountStore plus HTTP integration on the tracker:
|
||||
register/login/logout, per-account balance and usage, API-key lifecycle
|
||||
(revoked keys rejected by the OpenAI proxy), and the admin listing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from meshnet_tracker.accounts import AccountStore
|
||||
from meshnet_tracker.billing import BillingLedger
|
||||
from meshnet_tracker.server import TrackerServer
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- unit tests
|
||||
|
||||
|
||||
def test_first_account_is_admin_then_users():
|
||||
store = AccountStore()
|
||||
first = store.register(email="admin@example.com", password="secret-123")
|
||||
second = store.register(email="user@example.com", password="secret-123")
|
||||
assert first["role"] == "admin"
|
||||
assert second["role"] == "user"
|
||||
|
||||
|
||||
def test_register_requires_email_or_wallet_and_password_length():
|
||||
store = AccountStore()
|
||||
with pytest.raises(ValueError, match="email or a wallet"):
|
||||
store.register(password="secret-123")
|
||||
with pytest.raises(ValueError, match="invalid email"):
|
||||
store.register(email="not-an-email", password="secret-123")
|
||||
with pytest.raises(ValueError, match="at least 8"):
|
||||
store.register(email="a@b.co", password="short")
|
||||
|
||||
|
||||
def test_register_rejects_duplicate_identifiers():
|
||||
store = AccountStore()
|
||||
store.register(email="dup@example.com", password="secret-123")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
store.register(email="DUP@example.com", password="other-secret")
|
||||
|
||||
|
||||
def test_login_by_email_or_wallet():
|
||||
store = AccountStore()
|
||||
account = store.register(
|
||||
email="both@example.com", wallet="WalletXYZ", password="secret-123"
|
||||
)
|
||||
assert store.verify_login("both@example.com", "secret-123")["account_id"] == account["account_id"]
|
||||
assert store.verify_login("WalletXYZ", "secret-123")["account_id"] == account["account_id"]
|
||||
assert store.verify_login("both@example.com", "wrong-password") is None
|
||||
assert store.verify_login("nobody@example.com", "secret-123") is None
|
||||
|
||||
|
||||
def test_sessions_resolve_and_destroy():
|
||||
store = AccountStore()
|
||||
account = store.register(email="s@example.com", password="secret-123")
|
||||
token = store.create_session(account["account_id"])
|
||||
assert store.session_account(token)["account_id"] == account["account_id"]
|
||||
store.destroy_session(token)
|
||||
assert store.session_account(token) is None
|
||||
assert store.session_account("bogus") is None
|
||||
|
||||
|
||||
def test_api_key_lifecycle():
|
||||
store = AccountStore()
|
||||
account = store.register(email="k@example.com", password="secret-123")
|
||||
other = store.register(email="other@example.com", password="secret-123")
|
||||
key = store.create_api_key(account["account_id"])
|
||||
assert key.startswith("sk-mesh-")
|
||||
assert store.keys_for(account["account_id"]) == [key]
|
||||
# someone else's account cannot revoke it
|
||||
assert store.revoke_api_key(other["account_id"], key) is False
|
||||
assert store.revoke_api_key(account["account_id"], key) is True
|
||||
assert store.keys_for(account["account_id"]) == []
|
||||
assert store.is_key_revoked(key)
|
||||
|
||||
|
||||
def test_accounts_persist_across_restart(tmp_path):
|
||||
db = str(tmp_path / "accounts.db")
|
||||
store = AccountStore(db_path=db)
|
||||
account = store.register(email="p@example.com", password="secret-123")
|
||||
key = store.create_api_key(account["account_id"])
|
||||
store.save_to_db()
|
||||
|
||||
reloaded = AccountStore(db_path=db)
|
||||
assert reloaded.verify_login("p@example.com", "secret-123") is not None
|
||||
assert reloaded.keys_for(account["account_id"]) == [key]
|
||||
|
||||
|
||||
def test_account_events_replicate_and_dedupe():
|
||||
leader = AccountStore()
|
||||
follower = AccountStore()
|
||||
account = leader.register(email="r@example.com", password="secret-123")
|
||||
key = leader.create_api_key(account["account_id"])
|
||||
leader.revoke_api_key(account["account_id"], key)
|
||||
|
||||
events, cursor = leader.events_since(0)
|
||||
assert follower.apply_events(events) == len(events)
|
||||
assert follower.apply_events(events) == 0 # replay is a no-op
|
||||
assert follower.verify_login("r@example.com", "secret-123") is not None
|
||||
assert follower.is_key_revoked(key)
|
||||
more, _ = leader.events_since(cursor)
|
||||
assert more == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------- HTTP integration
|
||||
|
||||
|
||||
def _call(url, method="GET", body=None, token=None):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account_tracker():
|
||||
ledger = BillingLedger(starting_credit=0.5, default_price_per_1k=0.02)
|
||||
tracker = TrackerServer(billing=ledger, accounts=AccountStore())
|
||||
port = tracker.start()
|
||||
yield f"http://127.0.0.1:{port}", ledger
|
||||
tracker.stop()
|
||||
|
||||
|
||||
def test_register_login_and_account_view(account_tracker):
|
||||
url, _ = account_tracker
|
||||
reg = _call(f"{url}/v1/auth/register", "POST",
|
||||
{"email": "admin@example.com", "password": "secret-123"})
|
||||
assert reg["account"]["role"] == "admin"
|
||||
assert reg["api_key"].startswith("sk-mesh-")
|
||||
assert reg["session_token"]
|
||||
|
||||
login = _call(f"{url}/v1/auth/login", "POST",
|
||||
{"identifier": "admin@example.com", "password": "secret-123"})
|
||||
me = _call(f"{url}/v1/account", token=login["session_token"])
|
||||
assert me["account"]["email"] == "admin@example.com"
|
||||
assert me["api_keys"] == [reg["api_key"]]
|
||||
# registration granted Caller Credit on the ledger
|
||||
assert me["total_balance"] == pytest.approx(0.5)
|
||||
assert me["usage"]["requests"] == 0
|
||||
|
||||
|
||||
def test_bad_credentials_and_missing_session_are_401(account_tracker):
|
||||
url, _ = account_tracker
|
||||
_call(f"{url}/v1/auth/register", "POST",
|
||||
{"email": "a@example.com", "password": "secret-123"})
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_call(f"{url}/v1/auth/login", "POST",
|
||||
{"identifier": "a@example.com", "password": "wrong-pass"})
|
||||
assert exc_info.value.code == 401
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_call(f"{url}/v1/account")
|
||||
assert exc_info.value.code == 401
|
||||
|
||||
|
||||
def test_key_create_revoke_and_revoked_key_rejected_by_proxy(account_tracker):
|
||||
url, _ = account_tracker
|
||||
reg = _call(f"{url}/v1/auth/register", "POST",
|
||||
{"email": "k@example.com", "password": "secret-123"})
|
||||
token = reg["session_token"]
|
||||
|
||||
new_key = _call(f"{url}/v1/account/keys", "POST", {}, token=token)["api_key"]
|
||||
me = _call(f"{url}/v1/account", token=token)
|
||||
assert sorted(me["api_keys"]) == sorted([reg["api_key"], new_key])
|
||||
|
||||
_call(f"{url}/v1/account/keys/revoke", "POST", {"api_key": new_key}, token=token)
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_call(f"{url}/v1/chat/completions", "POST",
|
||||
{"model": "any", "messages": []}, token=new_key)
|
||||
assert exc_info.value.code == 401
|
||||
assert "revoked" in exc_info.value.read().decode()
|
||||
|
||||
|
||||
def test_admin_listing_requires_admin_role(account_tracker):
|
||||
url, _ = account_tracker
|
||||
admin = _call(f"{url}/v1/auth/register", "POST",
|
||||
{"email": "admin@example.com", "password": "secret-123"})
|
||||
user = _call(f"{url}/v1/auth/register", "POST",
|
||||
{"wallet": "WalletUser1", "password": "secret-123"})
|
||||
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_call(f"{url}/v1/admin/accounts", token=user["session_token"])
|
||||
assert exc_info.value.code == 403
|
||||
|
||||
listing = _call(f"{url}/v1/admin/accounts", token=admin["session_token"])
|
||||
accounts = listing["accounts"]
|
||||
assert len(accounts) == 2
|
||||
assert accounts[0]["role"] == "admin"
|
||||
assert accounts[1]["wallet"] == "WalletUser1"
|
||||
assert "balances" in accounts[0]
|
||||
|
||||
|
||||
def test_accounts_gossip_endpoint_applies_events(account_tracker):
|
||||
url, _ = account_tracker
|
||||
peer = AccountStore()
|
||||
peer.register(email="remote@example.com", password="secret-123")
|
||||
events, _ = peer.events_since(0)
|
||||
result = _call(f"{url}/v1/accounts/gossip", "POST", {"events": events})
|
||||
assert result["applied"] == len(events)
|
||||
login = _call(f"{url}/v1/auth/login", "POST",
|
||||
{"identifier": "remote@example.com", "password": "secret-123"})
|
||||
assert login["account"]["email"] == "remote@example.com"
|
||||
|
||||
|
||||
def test_accounts_endpoints_404_when_disabled():
|
||||
tracker = TrackerServer() # no accounts, no billing
|
||||
port = tracker.start()
|
||||
try:
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_call(f"http://127.0.0.1:{port}/v1/auth/register", "POST",
|
||||
{"email": "x@example.com", "password": "secret-123"})
|
||||
assert exc_info.value.code == 404
|
||||
finally:
|
||||
tracker.stop()
|
||||
@@ -103,6 +103,22 @@ def test_restart_persistence(tmp_path):
|
||||
assert reloaded.snapshot()["protocol_cut"] == pytest.approx(0.02 * 0.10)
|
||||
|
||||
|
||||
def test_tracker_enables_billing_with_default_db_when_requested(tmp_path, monkeypatch):
|
||||
from meshnet_tracker.billing import DEFAULT_BILLING_DB_PATH
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
tracker = TrackerServer(enable_billing=True)
|
||||
port = tracker.start()
|
||||
try:
|
||||
summary = json.loads(
|
||||
urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/billing/summary").read()
|
||||
)
|
||||
assert "protocol_cut" in summary
|
||||
finally:
|
||||
tracker.stop()
|
||||
assert (tmp_path / DEFAULT_BILLING_DB_PATH).exists()
|
||||
|
||||
|
||||
def test_event_replication_converges_and_dedupes():
|
||||
leader = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
follower = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
|
||||
Reference in New Issue
Block a user