Private release previewNo live purchasesRelease details ↗

EN · ORIGINAL REPOSITORY DOCUMENT

Customer portal and administration

Original English text. Commands and evidence apply to the revision and environment stated in the document.

product/licensing/go/PORTAL.md

On this page

Local-test customer portal

The optional portal authenticates pre-existing fixture customers, lists their manually issued licenses and approved sites, and downloads the configured original ZIP for a currently valid owned license. The Astro website adds an explicitly authorized operator administration screen and isolated test checkout. Ordinary customers cannot issue licenses or approve sites. The portal does not register accounts, send mail, charge customers or activate installations.

The durable provider preserves operator-provisioned test identities and recovery changes across process restarts. Provisioning remains explicitly test-only and local; it creates no production account, external identity, mail or billing service. Keep portal flags absent when running the ordinary licensing service. No real customer credentials belong in a test fixture. Go 1.24 or newer is required for the standard-library crypto/pbkdf2 implementation; this milestone was originally tested with Go 1.26.1; the Astro/admin extension passed the race suite and vet with Go 1.27.1.

Explicit one-time provisioning and durable startup

Create a dedicated private directory (mode 0700) and synthetic fixture JSON (mode 0600) using the schema below. Provision a separate new identity state:

original-licensing portal-provision \
  --portal-state /private/local-fixture/accounts.json \
  --fixture /private/local-fixture/portal.json --confirm-test-provision

Provisioning refuses every existing target, including empty or corrupt files. It writes only salted password hashes, recovery-code hashes, login/customer IDs, consumption flags and revisions, with mode 0600. It does not retain a plaintext fixture copy in account state. After successful provisioning, remove the private plaintext test fixture from the active service’s inputs. Startup never reads or reimports it; the accounts file is the sole identity source.

The root CLI enables durable portal mode when --portal-state, --portal-origin and --portal-site are supplied together:

original-licensing --database /private/local-fixture/licenses.json serve \
  --private-key /private/local-fixture/private.pem \
  --original-package /private/local-fixture/original.zip --version 0.1.0 \
  --port 8092 \
  --portal-state /private/local-fixture/accounts.json \
  --portal-origin http://127.0.0.1:8092 \
  --portal-site /absolute/path/to/product/site/dist \
  --portal-admins customer-a

Set the existing LICENSING_SIGNING_KEY environment variable to the private server-only fixture HMAC material before running this command, without logging it or placing it in shell arguments. The CLI requires the portal origin to match its literal loopback listener and port. Open the same http://127.0.0.1:8092 origin in the browser. The root-owned static handler serves the opt-in portal UI and generated client configuration. Run npm ci && npm run build in product/site first. The Astro public manifest and its generated assets in dist are required; source files are not served. Legacy service archives containing the original static fixture still use that fixture until rebuilt with the Astro output. Omit --portal-admins when no customer is to receive operator privileges; Portal itself handles only the API routes below. Existing WordPress licensing API routes retain their own bearer/installation contracts.

Create a private directory (mode 0700) and a fixture JSON file (mode 0600) containing only synthetic local credentials, for example:

{
  "schema": 1,
  "fixture_only": true,
  "customers": [
    {
      "customer_id": "customer-a",
      "login": "alice",
      "password": "fixture-password-alice",
      "recovery_code": "fixture-recovery-alice-001"
    }
  ]
}

The loader reads at most 64 KiB, accepts 1–100 customers, and rejects unknown or duplicate JSON fields, trailing JSON, wrong schema, absent fixture acknowledgment, duplicate customer IDs/logins/recovery codes, and recovery codes equal to any password. Customer IDs use the existing opaque operator-reference format. Logins are 1–128 UTF-8 bytes without surrounding whitespace or NUL/newlines. Passwords and recovery codes are 16–128 UTF-8 bytes without NUL/newlines. IDs must correspond to licenses already issued through the manual customer fulfillment flow; loading the fixture grants no license rights. The importer does not write the fixture. Use independently random high-entropy recovery codes for even durable synthetic accounts, such as 32 random bytes encoded with unpadded URL-safe base64. The readable codes above are examples for disposable tests, not real recovery codes.

API contract

All responses have Cache-Control: no-store; no CORS response is enabled. Requests must use the exact configured Host. Every POST must supply the exact configured Origin; any supplied Origin on a GET must also match. Cross-site fetch metadata, duplicate Origin headers, query strings, and escaped alternative API paths are rejected. POST bodies require application/json, known fields, no duplicates or trailing JSON, and a declared length of 1–4096 bytes. Every POST body on a known endpoint is read, capped at 4096 bytes, before the shared state mutex is taken, whatever its declared length or media type, so a slow or chunked peer cannot hold the mutex. A body over the cap (declared or actual) is rejected before authentication with 413 (code: invalid_request); in-limit bodies keep the existing validation order and 400 failures. A handler path that never sets a status returns a generic 500 JSON failure. The one exception to the 4096-byte cap is the administrator ZIP upload described in “Plugin packages”, which streams up to 64 MiB to a temp file, also outside the mutex.

Method and path JSON body / result
GET /portal/session Authenticated {customer_id, csrf_token, expires_at}; anonymous 401.
POST /portal/login {login, password} → session response and cookie.
POST /portal/recover {login, recovery_code, new_password} → {recovered:true}; consumes code and invalidates this customer’s sessions.
POST /portal/logout {} plus X-CSRF-Token → {logged_out:true} and expired cookie.
GET /portal/licenses Authenticated {licenses:[...], package} containing only owned licenses; package is {version, sha256, size} for the active plugin ZIP, or null.
POST /portal/download {license_id} plus X-CSRF-Token → ZIP bytes for a valid owned license.

Session/login responses use Unix seconds for expires_at. The CSRF token is random and bound to one session; it is distinct from the opaque cookie value. The cookie is original_portal_session, with HttpOnly, SameSite=Strict, Path=/portal, and a fixed 15-minute lifetime. It uses Secure on HTTPS; the explicit loopback HTTP fixture omits that attribute. Sessions do not slide or persist across service restart. At most 1024 live sessions are retained.

Each license catalog item contains:

license_id, order_id, status, expires, site_limit,
approved_origins[], active_sites[], version, sha256

license_id is the existing state SHA256 identifier, not a license bearer. The server independently checks customer ownership when downloading; knowledge of another license’s identifier grants no access. Status is valid, expired, or revoked. Origin arrays are sorted; active_sites is empty for expired/revoked licenses. The catalog includes no license keys, installation keys/digests, download tokens, credentials, recovery codes, or cookie values.

Downloads return application/zip, attachment filename original-product.zip, and X-Content-SHA256 matching the active package (see “Plugin packages”) and catalog hash. The server reloads state under the existing fixed transaction lock and checks ownership, expiry, revocation, and package availability on each download. It needs no site activation for the initial customer download and grants no installation or site approval. A checked download is authorized at that transaction; a later revoke does not recall bytes already being served.

Authentication/session failures return 401, origin/CSRF/owned-download denials return 403, invalid bodies return 400, unknown routes return 404, and license-state-read/capacity failures return 503. Missing, corrupt or quarantined durable identities invalidate existing sessions and return 401. Error responses disclose no credentials or ownership detail.

Provider and recovery boundary

The integration APIs are:

ProvisionPortalFixture(statePath, fixturePath string, confirmTestProvision bool) (*DurablePortal, error)
OpenDurablePortal(statePath string) (*DurablePortal, error)
LoadPortalFixture(path string) (PortalProvider, error) // legacy memory-only tests
NewPortal(store *Store, provider PortalProvider, origin string) (*Portal, error)

type PortalProvider interface {
    Authenticate(login, password string) (customerID string, err error)
    Recover(login, recoveryCode, newPassword string) (customerID string, err error)
}

type DurablePortalProvider interface {
    PortalProvider
    AuthenticateVersion(login, password string) (customerID string, revision uint64, err error)
    Revision(customerID string) (uint64, error)
}

Portal implements http.Handler. The CLI restricts both test providers to its matching literal 127.0.0.1 HTTP origin. A future external provider would need its own identity/recovery implementation and explicit trusted HTTPS configuration. This interface does not establish an external or production identity service.

The durable schema is independent from license state: schema 1, a customers map keyed by opaque customer IDs, and mandatory login, password salt/hash/work factor, recovery hash/consumption, revision and quarantine fields. Each read rejects duplicates, unknown/missing/null fields, invalid work factors, malformed hashes/salts, duplicate logins, more than 100 customers, or more than 64 KiB. State and its immediate directory must be private regular-file/directory paths; symlink and hardlink aliases are rejected, so separate pathname locks cannot operate on the same account inode. Missing/corrupt files after provisioning fail closed and are never recreated by authentication or recovery.

Passwords use standard-library PBKDF2-HMAC-SHA256 with 600,000 iterations, a fresh 32-byte random salt, and a 32-byte derived key. Recovery codes use SHA256 hashes of the high-entropy code; neither credential is persisted in plaintext. Recovery reloads state under the fixed accounts.json.lock, consumes a code, derives a fresh salted new password hash, and increments the customer’s revision in one transaction. Publishing fsyncs a mode-0600 temporary file, renames it atomically, and fsyncs its directory. Failed validation/callbacks publish nothing. As with license state, a post-rename directory-fsync failure can report an error after the new state became visible; do not retry by reprovisioning or replacing state.

AuthenticateVersion checks the password and returns its revision in the same locked transaction. The handler stores that revision with its memory-only session, checks the current durable Revision on every authenticated request, and deletes/denies mismatches. Recovery through another process therefore invalidates old sessions on their next request. Within one portal, credential changes, authentication/session issuance and session invalidation share a mutex. Recovery invalidates every session belonging to that customer, preserves other customers’ sessions, and requires new-password login. A used code stays consumed across process restart and a current consistent backup restore. New passwords cannot equal any stored recovery code; an exhausted revision cannot wrap.

The legacy LoadPortalFixture provider remains available for disposable HTTP/UI tests using --portal-fixture in place of --portal-state. It keeps credentials and code consumption only in memory and resets from its fixture on restart. It must not be used to assert durability. The two flags cannot be supplied together.

Consistent identity backup and conservative restore

Identity state is sensitive even though it contains credential hashes. Retain it privately with the license snapshot and server signing material. Stop the service and concurrent operator writers before copying, or hold the same fixed state lock throughout each snapshot copy. Do not copy temporary publish files or substitute a lock on the renameable JSON inode. Preserve private directory and file permissions. For a coherent identity/license pair, stop their writers before copying both files.

A cold restore of the current consistent identity snapshot into a NEW explicit path preserves its new password hash, consumed recovery flag, and revision. Sessions remain memory-only and all pre-restart cookies are rejected. Never replace a running identity file with an older backup: an unsigned snapshot has no independent freshness proof, and an old snapshot alone can resurrect an old password or an unused recovery code. Restart alone cannot detect that rollback.

Keep every restored older or uncertain state isolated and OFF. Independently obtain the latest authoritative identity snapshot and its exact SHA256 digest, then reconcile the distinct restored path before considering service startup:

original-licensing portal-reconcile \
  --portal-state /private/restored/accounts.json \
  --authoritative /private/latest/accounts.json \
  --authoritative-sha256 "$IDENTITY_AUTHORITY_SHA256" \
  --confirm-authoritative-latest

The provider method is:

func (*DurablePortal) ReconcileRestoredIdentity(
    authoritativePath, authoritativeSHA256 string, confirmLatest bool,
) (IdentityReconcileReport, error)

This operation requires explicit external latest-authority acknowledgment and an exact digest. A digest identifies the chosen bytes; it does not authenticate them or establish freshness. Both files must already exist and be valid. Same path, symlink/hardlink aliases, shared lock identity, missing/corrupt inputs and digest mismatch are rejected without publishing target changes. Locks are acquired in sorted path order and held through target fsync/rename, so opposite path reconciliations cannot deadlock. Authoritative bytes are never overwritten.

Restored customer IDs/logins and credential hashes are never reassigned or imported from authority. Unknown restored customers and any differing login, salt, password hash, work factor, recovery hash/consumption or revision are permanently quarantined, with their recovery codes consumed. This includes same-revision credential edits and both lower and higher restored revisions. Authority quarantine and existing restored quarantine are sticky. Latest-only customers are not imported. Matching unchanged customers retain their identity and revision. Quarantined accounts deny old AND new passwords, recovery and session revision checks; this operation has no unquarantine path. Revision exhaustion cannot undo quarantine or reauthorize old sessions.

The aggregate receipt contains only authoritative_sha256, customers_reviewed, customers_quarantined (new quarantines), unknown_customers, credential_conflicts and recovery_codes_consumed (new consumption). It contains no logins, customer IDs, credential hashes/salts or bearer material. Quarantine requires a separately authorized operator identity-remediation procedure; this milestone provides no automatic identity regrant. Reconcile the license backup separately under the license recovery procedure.

Focused validation

go test -race -run '^TestPortal|^TestDurablePortal' -v ./...

Six tests use actual httptest HTTP servers and synthetic original ZIPs. They cover login/catalog/download, cross-customer and legacy-license denial, expired and revoked licenses, corrupt-state refusal, logout/session expiry, same-origin and CSRF checks, strict bodies/fixture loading, no catalog bearer disclosure, recovery invalidation and new password login, and eight simultaneous recoveries with exactly one successful code consumption. Eight additional durable tests cover explicit provisioning/private hashed storage, reopen without the fixture, actual child-process recovery/revision observation, two-provider concurrent provisioning/recovery, current-backup restart, older/same-revision credential conflict quarantine, unsigned-authority input refusal, missing/corrupt/incomplete state, hardlink alias refusal for existing providers, and opposite-path lock ordering. A subprocess-helper test entry is skipped in the parent suite and executed by its actual child-process test. All test credentials, state, keys, ZIPs, and listeners are temporary and removed by the test harness.

The repository’s integrated disposable harness additionally starts the actual CLI binary, issues two synthetic customer licenses through manual fulfillment, and runs real HTTP plus installed Chromium UI flows with the configured ZIP:

python3 product/licensing/portal/check.py \
  --binary /absolute/path/to/original-licensing \
  --package /absolute/path/to/reviewed-paid-plugin.zip

Run that command from any directory; its default report directory is repository build/portal-check. Python 3.10+, OpenSSL, Node/Playwright and installed Chromium are needed. No browser download, mail provider or payment service is used. It removes its temporary credentials, license state, RSA/HMAC keys and Go process, checks cleanup, and returns a failing result if any functional or cleanup gate fails. Do not use production keys/accounts or run against a live service.

The durable lifecycle harness provisions once, consumes recovery through real HTTP, restarts the actual CLI, cold-restores the latest backup, and reconciles an older isolated backup against the explicitly trusted latest snapshot:

python3 product/licensing/portal/durable-check.py \
  --binary /absolute/path/to/original-licensing \
  --package /absolute/path/to/reviewed-paid-plugin.zip \
  --output /absolute/private/build/portal-durable-check

It uses synthetic accounts and temporary private state; no real mail, billing, external identity, public deployment, or production key is required.

Astro website and explicit administration

The same binary can serve Astro’s generated dist/site-manifest.json and public assets. Routes /tr/ and /en/ provide product pages, nine translated guides, original reference documents, customer account, demo and test checkout. Administration lives at a private, unlinked prefix (Turkish at its root, English under en/; see the operator deployment notes); the old /tr/admin/ and /en/admin/ routes no longer exist and return 404. Pages under that prefix (including its 404s and the trailing-slash redirect) are served from the same manifest but always carry X-Robots-Tag: noindex, nofollow and Cache-Control: no-store. The prefix is obscurity, not authorization: every administration API call still requires the explicit administrator role below. The manifest is snapshotted at startup, has bounded file/count/total sizes and rejects traversal and symlink aliases. Only listed public file types are served. Source files, arbitrary paths, keys and identity state are not exposed. HTML uses a restrictive CSP with hashes for Astro-generated inline scripts; it does not require unsafe-inline. GET/HEAD are accepted for static pages; missing routes return a bilingual 404. Rebuild and restart after frontend edits.

Administrator access is opt-in at startup:

# Add to the durable serve command above only for independently authorized IDs:
--portal-admins customer-a,customer-operator

These IDs must match the authenticated customer’s customer_id; a login name is not a role. No public API changes roles or provisions identities. An ordinary customer requesting /portal/admin/* receives 403. Every authenticated request still checks durable credential revision, expiry, origin and session state. Mutation routes require the existing X-CSRF-Token and same-origin POST checks.

Session/login responses retain their existing fields and add server_time (Unix seconds) and admin (boolean). Browsers calculate the visible expiry from server time, clear private state when it passes and ignore older async results. Error JSON retains error and adds a stable code for translated messages; 503 responses include Retry-After: 5.

Route Access and behavior
GET /portal/orders Signed-in customer’s test orders only
GET /portal/admin/orders All test orders; administrator only
GET /portal/admin/licenses Administrator catalog with customer references; no bearer keys
GET /portal/admin/analytics Administrator-only current license/order snapshot, bounded observations and service counters; no query parameters
POST /portal/admin/fulfill customer_id, unique verified order_id, future expires, site_limit, confirmed:true
POST /portal/admin/renew Matching customer_id and license_id, expires, site_limit, confirmed:true; cannot shorten expiry, revive revocation or reduce below active usage
POST /portal/admin/approve Matching customer/license, verified HTTPS site origin, confirmed:true; requires active license
POST /portal/admin/revoke Matching customer/license and confirmed:true; revocation is permanent
GET /portal/admin/packages {active, packages}; see “Plugin packages”
POST /portal/admin/packages Raw ZIP upload; see “Plugin packages”
POST /portal/admin/packages/activate {sha256}; activates a stored package (rollback)

Manual fulfillment uses the existing customer/order uniqueness constraints and returns a bearer key once. Exact replay never returns it again. TEST- order references are rejected for manual fulfillment. Before confirming, operators independently verify the customer, order and domain authority. The UI shows the one-time key before refreshing the catalog, so a subsequent read failure does not hide it. Closing the dialog, session expiry and navigation clear it.

Administration analytics

The Astro administration page can sign in directly and exposes Overview, Licenses, Customers, Payments, Services and Settings. Analytics uses the same explicit role, session revision, expiry and exact-origin checks as other admin routes. The schema:1 response contains generated_at, licenses, orders, service, daily and activity. It exposes no bearer, cookie, password, installation secret, private path or request body.

The current license catalog comes from the existing durable store. Customer counts refer to assigned licenses; customer detail also includes test-order references, not a directory of all provisioned identities. Current state is never reported as historical license creation or revenue growth. Test totals use the existing server-priced order records, count replayed orders once and remain explicitly test-only. The frontend’s 7/30/90-day periods are UTC calendar days, including the current day; the expiry queue always covers the next 30 days.

Observations start with this Go process. service.storage is process_memory: up to 90 daily buckets and the last 200 operator/payment actions are retained; they reset on restart. Test orders may use the optional durable JSON ledger described below. No new service-metrics database or external analytics service is introduced. Lifetime counters are separate from the selected daily period. Average response time measures server handling, not network latency. Successful downloads count HTTP 2xx responses, not confirmation that the end user saved a file. 4xx rejects are distinct from 5xx service errors.

Only fixed licensing and portal operation paths are observed. Assets, unknown paths, /healthz, /portal/session and admin reads are excluded so polling does not inflate usage. Request URLs, bodies, headers and IPs are never retained. Operator events are recorded only after a changed successful action; issuance replay, repeated approval/revocation and unchanged renewal do not add events. Historical CLI actions, pre-start observations, visits, unique visitors, Redis cache metrics and real payment revenue are unavailable rather than invented.

The frontend refreshes manually or optionally every 30 seconds while visible. Failed refreshes visibly mark the prior snapshot stale and disable writes and CSV export until a successful retry. Logout/expiry clears rendered private references and cancels older requests. CSV includes scope, timestamps and units, quotes all values and neutralizes spreadsheet formula prefixes.

For development, Astro on 127.0.0.1:8765 forwards only /portal/* to the default Go origin 127.0.0.1:8766, after exact browser Host/Origin checks. Go’s own static hosting still serves the generated site and API on one origin without this development bridge. See the website README for startup and fixture commands.

Plugin packages

Customers download the plugin as a ZIP from the portal (POST /portal/download) and licensed sites fetch it with a download token (/download). Packages come from two sources:

  • build: the existing --original-package PATH --version X.Y.Z serve flags.
  • upload: an administrator upload through POST /portal/admin/packages.

Both are stored in packages/ next to the license database (the directory of --database) as <sha256>.zip, described by packages/manifest.json. The manifest is written by temp file, fsync, rename and directory fsync under a manifest.json.lock file lock; stale temp files are removed at startup. At most 20 packages are kept: older inactive records are pruned (never the active or the current build package). Exactly one package is active, and every download, update and entitlement path reads it through one immutable snapshot.

Package objects are {version, sha256, size, source: "build"|"upload", created_at, active} with created_at in Unix seconds.

Route Behavior
GET /portal/admin/packages 200 {"active": Package|null, "packages": [Package...]}, newest first, at most 20
POST /portal/admin/packages Body is the raw ZIP, Content-Type: application/zip, header X-Package-Version: MAJOR.MINOR.PATCH[-prerelease] (no build metadata). 201 {"package": Package}; the upload becomes active
POST /portal/admin/packages/activate JSON {"sha256": "<hex>"} (normal 4 KiB JSON body) → 200 {"package": Package}; unknown hash 404 (code: not_found)

Uploads are the only exception to the 4 KiB pre-lock body cap. The service briefly takes the state mutex only to check session, CSRF and administrator role, releases it, then streams the body (declared or chunked) into a 0600 temp file in packages/ with a 64 MiB cap and an extended 5-minute read deadline. One upload runs at a time (409, code: service_busy). The ZIP is then validated outside the mutex: it must be a readable archive with a single top-level plugin directory, no top-level files, no absolute, backslash or .. entry names, and at most 20 000 entries. The main plugin file is object-cache-pro/object-cache-pro.php; for another directory name it is the single PHP file directly inside that directory with a Plugin Name: header. Its Version: header (first 8 KiB, as WordPress reads it) must equal X-Package-Version. Only then is the state mutex taken again: session, CSRF and role are re-checked, the temp file is renamed to <sha256>.zip and the manifest is published. Every failure path removes the temp file.

Failure Status / code
Not signed in / missing CSRF / not an administrator 401 session_required / 403 session_mismatch / 403 admin_required
Declared or streamed body over 64 MiB 413 invalid_request
Wrong media type, invalid version header, invalid ZIP or layout 400 invalid_package
Plugin Version: header differs from X-Package-Version 400 version_mismatch
Same version already stored with a different hash 409 version_conflict (the active package is unchanged)

Re-uploading identical bytes is idempotent and re-activates that package. Uploads and activations are recorded as administrator writes in analytics (package_uploaded, package_activated activity; package reads are not observed). No package bytes, paths or request bodies are logged.

Activating a different package changes the bytes and X-Content-SHA256 of new downloads immediately. /updates and the customer catalog report the active version and hash; /entitlement adds an informational, unsigned version field beside the signed envelope. Download tokens bind the package hash, so a token issued for a previous package keeps failing with the existing 403.

Startup selection rule. At startup the build package is registered as source build. If the persisted active package is an upload whose semantic version is greater than or equal to the build version, it stays active; otherwise (no active package, an older upload, or any build package from a previous deploy) the build package becomes active, so a new deploy with newer code is never shadowed by an old upload. Prerelease versions sort below their release (2.0.0-rc.1 < 2.0.0). Versions stay unique: if an upload already owns the build’s exact version with different bytes, that upload represents the deploy and the build bytes are not registered; an older build registration of the same version is retired. An administrator rollback to an older package therefore lasts until the next restart with a newer build. Without --original-package, the persisted active package is restored. A corrupt manifest or an active package whose size or hash no longer matches stops startup (fail closed).

Test checkout contract

POST /portal/test-checkout takes only plan, scenario and request_id. The plan is one of starter, studio, platform. Scenarios are paid, declined, cancelled, all explicitly test-only. The server determines example USD minor-unit amounts (4900, 12900, 29900); a browser cannot submit an amount or card details. The request ID is a 16–64-character operator-reference-compatible value; the browser uses a UUID. The same customer’s same request/payload returns the original record. Changing the payload for an existing request returns 409. Other customers have a separate idempotency namespace and cannot read the order.

The result contains id prefixed TEST-, customer_id, plan name, amount, currency:"USD", scenario in status, test:true, creation time and request ID. These records are bounded (at most 10,000). Without --portal-orders they use process memory and disappear on restart. With a durable identity provider, pass --portal-orders /private/test-orders.json to retain them. Provision that file once as {"schema":1,"orders":{}} in a private 0700 directory with mode 0600. Existing corrupt/missing/unsafe ledger files are rejected, never replaced. Writes reload under a fixed file lock and publish by atomic rename with file and directory fsync, following the existing Go state approach. Replay remains idempotent across restarts; identities, amounts, outcomes and test markers are validated when reading. order_storage in the analytics response distinguishes durable_json from process_memory. This endpoint never invokes the license store or a payment provider.

The persistent local setup supplies this ledger, durable identities, an empty license catalog, and stable signing keys in a Docker volume. It replaces the disposable browser helper for normal local development without changing the Go/JSON architecture or enabling public accounts. No charge, paid subscription, invoice, refund, tax calculation or entitlement is created. The UI’s receipt is labelled as a test, not an invoice. Provider-backed billing requires the user’s later provider choice and integration configuration.