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.Zserve 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.