Server: web client under /app/ (D-132), analytics E-34 trends and E-35 forecast (D-133)
Build Kvitt images / build (push) Failing after 1m20s
Build Kvitt images / build (push) Failing after 1m20s
Part A (D-132): KVITT_WEB_DIR (default /app) served under /app/ by a small handler: /app redirects to /app/, app routes fall back to index.html, a missing assets/* file is 404, assets cached immutable, index.html no-store, the app's own CSP plus nosniff, DENY and no-referrer. Unsafe paths never reach the file system. /app paths count against the unauthenticated per-IP limit. Without the directory every /app path stays 404 not_found. server/Dockerfile gains a Node 24 LTS stage (pinned by digest) building web/, passed through the Rust stage so npm and rustc never run at once on the Pi 3; the build context is the repository root with an allowlist .dockerignore (server/.dockerignore removed); workflow, build-images.sh and infra/dev/build.sh follow. Part B (D-133): GET /analytics/trends (E-34) and GET /analytics/forecast (E-35), authenticated, read-only, SQL month buckets; rules (half-up average, trailing 3-month moving average, percent against the rounded average, template occurrence per month) in kvitt-domain::analytics. OpenAPI, examples, contracts-check and tests updated (35 operations). Cargo.lock: rustls-webpki 0.103.16 and webpki-root-certs 1.0.10 (RUSTSEC-2026-0382, -0383) so that cargo audit passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -769,6 +769,7 @@ DEFAULT_KEY = E(*DEFAULT_KEYS)
|
||||
KEYWORD_MATCHES = ["token", "prefix", "phrase"]
|
||||
SPLIT_MODE = E("default", "ratio", "exact", "full")
|
||||
ENTITY_TYPE = E("expense", "settlement")
|
||||
MEASURE = E("household", "my_share")
|
||||
REASON = E("stale", "deleted", "validation_failed", "reference_not_found", "clock_skew")
|
||||
ERROR_CODES = ["invalid_request", "invite_invalid", "unauthorized", "device_revoked", "forbidden", "not_found",
|
||||
"household_full", "household_incomplete", "name_taken", "account_not_empty", "category_in_use",
|
||||
@@ -853,6 +854,16 @@ SHAPES = {
|
||||
"ProposalRequest": S({"payer_user_id": "uuid", "amount_ore": "int", "exclude_settlement_id": N("uuid")},
|
||||
optional=["exclude_settlement_id"]),
|
||||
"ProposalResponse": S({"allocations": A("Allocation"), "excess_ore": "int", "over_settled_account_ids": A("uuid")}),
|
||||
# E-34, E-35 analytics (D-133, §14.5.7)
|
||||
"TrendPoint": S({"month": "period", "amount_ore": "int", "moving_average_ore": "int"}),
|
||||
"Trend": S({"total_ore": "int", "average_ore": "int", "last_month_ore": "int", "last_month_vs_average_pct": N("int"),
|
||||
"current_month_ore": "int", "points": A("TrendPoint", 1, 24)}),
|
||||
"CategoryTrend": S({"category_id": "uuid", "trend": "Trend"}),
|
||||
"TrendsResponse": S({"measure": MEASURE, "today": "date", "current_month": "period", "months": "int",
|
||||
"household": "Trend", "categories": A("CategoryTrend")}),
|
||||
"ForecastMonth": S({"month": "period", "recurring_ore": "int", "baseline_ore": "int", "forecast_ore": "int"}),
|
||||
"AccountForecast": S({"account_id": "uuid", "current_month_ore": "int", "months": A("ForecastMonth", 2, 2)}),
|
||||
"ForecastResponse": S({"measure": MEASURE, "today": "date", "current_month": "period", "accounts": A("AccountForecast")}),
|
||||
# §14.4.2 and §14.4.3 sync
|
||||
"SyncPullResponse": S({"epoch": "hex32", "since": "int", "next_since": "int", "has_more": "bool", "server_time": "ts",
|
||||
"users": A("User"), "categories": A("Category"), "accounts": A("Account"),
|
||||
@@ -1097,6 +1108,8 @@ EXAMPLES = {
|
||||
"E-32-sync-push/response.json": "SyncPushResponse",
|
||||
"E-33-settlement-proposal/request.json": "ProposalRequest",
|
||||
"E-33-settlement-proposal/response.json": "ProposalResponse",
|
||||
"E-34-analytics-trends/response.json": "TrendsResponse",
|
||||
"E-35-analytics-forecast/response.json": "ForecastResponse",
|
||||
"sync/pull-page.json": "SyncPullResponse",
|
||||
"sync/pull-empty-page.json": "SyncPullResponse",
|
||||
"sync/pull-page-categories-v2.json": "SyncPullResponse",
|
||||
@@ -1458,6 +1471,7 @@ ENDPOINTS = [
|
||||
("E-27", "delete", "/categories/{category_id}"), ("E-28", "post", "/recurring-templates"),
|
||||
("E-29", "patch", "/recurring-templates/{template_id}"), ("E-30", "delete", "/recurring-templates/{template_id}"),
|
||||
("E-31", "get", "/sync"), ("E-32", "post", "/sync"), ("E-33", "post", "/settlements/proposal"),
|
||||
("E-34", "get", "/analytics/trends"), ("E-35", "get", "/analytics/forecast"),
|
||||
]
|
||||
UNAUTHENTICATED = {"E-1", "E-2", "E-3", "E-4", "E-5", "E-6"}
|
||||
|
||||
@@ -1500,7 +1514,7 @@ def check_openapi():
|
||||
ops[(cur_method, cur_path)]["security"] = m.group(1).strip()
|
||||
want = {(meth, p): eid for eid, meth, p in ENDPOINTS}
|
||||
got = {k: v["eid"] for k, v in ops.items()}
|
||||
report(got == want, "openapi operations are exactly E-1..E-33 of §7",
|
||||
report(got == want, "openapi operations are exactly E-1..E-35 of §7",
|
||||
"missing %s, extra %s, wrong ids %s" % (sorted(set(want) - set(got)), sorted(set(got) - set(want)),
|
||||
sorted(k for k in want if k in got and got[k] != want[k])) if got != want else "%d operations" % len(got))
|
||||
bad_sec = sorted(eid for (meth, p), eid in want.items() if (meth, p) in ops and
|
||||
|
||||
Reference in New Issue
Block a user