Help us shape the future of Go! Take the 2026 Go Developer Survey

backplane-lic

module
v0.0.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: MIT

README

backplane-lic

A Backplane tool service that reproduces the Keygen CE 1.7 licensing API 1:1 — same URLs, JSON:API documents, headers, error codes and behavior — so existing Keygen clients (keygen-go, vendor backends) cannot tell the difference. Single account (Keygen "singleplayer"), licensing scope only: no releases, artifacts, environments, event/request logs, SSO or billing. Managed from the Backplane console (internal gRPC + UI plugin, later phase).

Layout

cmd/lic/                 the binary: Config, composition via internal/service
internal/domain/         pure rules: entities, crypto helpers (account keys)
internal/app/            scenarios (accounts bootstrap today)
internal/infra/postgres/ schema.sql, sqld queries, generated db, repositories
internal/infra/httpapi/  ogen handler: JSON:API rendering, error mapping
internal/infra/portal/   embedded customer SPA and HTTP mount
portal/                  standalone React + Vite customer portal
internal/oas/            ogen output (generated — never edit)
internal/service/        composition root + Config
openapi/src/             OpenAPI 3.1 fragments (source of truth)
openapi/openapi.yaml     bundled spec (generated by `make spec`, committed)
deployments/             compose dev deps, Dockerfile, dev.yaml

Developing

make configure   # pinned tools into ./bin (sqld, golangci-lint, easyp, …)
make up          # postgres :5440, valkey :6390 (compose project `lic`)
make db-init     # regenerate the single init migration from schema.sql
make db          # generate typed queries + validate migrations
make spec        # bundle openapi/src → openapi/openapi.yaml
make gen-api     # … and generate the ogen server into internal/oas
make lint        # golangci-lint
make test        # unit tests (DB tests skip without LIC_TEST_DATABASE_URL)
make test-live   # unit tests against the compose PostgreSQL/Valkey
make portal      # install and build the customer SPA into the embed directory
make build       # build the portal first, then ./bin/lic

make down stops the dev dependencies. The keygen profile reproduces the Keygen CE reference server for the conformance suite on :3900 (do not run it while the kgce compose project holds the port): make keygen-up / make keygen-down; requests need Host: api.keygen.test and X-Forwarded-Proto: https, admin admin@example.com / adminadmin123.

Running

make build
BACKPLANE_CONFIG_FILE=deployments/dev.yaml \
BACKPLANE_PUBLIC_PORT=8090 BACKPLANE_INTERNAL_PORT=9440 \
BACKPLANE_INTERNAL_SECRET=dev-internal-secret-change-me \
  ./bin/lic

Then GET /v1/ping (200), GET /v1/health (204) and — once the middleware pipeline lands — the JSON:API under /v1/accounts/{account}/… with Keygen-compatible authentication and signatures.

Configuration

File (BACKPLANE_CONFIG_FILE) < env LIC_* (the SDK block reads BACKPLANE_*) < Consul KV for live fields. See deployments/dev.yaml:

Field Env Description
store.dsn LIC_STORE_DSN PostgreSQL connection string
cache.addr LIC_CACHE_ADDR Valkey address (cache-aside, optional)
account.id LIC_ACCOUNT_ID the single account's UUID, created at startup if missing
account.slug LIC_ACCOUNT_SLUG the account's slug (also its API address)
account.name LIC_ACCOUNT_NAME display name
http.portal.enabled LIC_HTTP_PORTAL_ENABLED serve the customer portal; defaults to false
http.portal.basePath LIC_HTTP_PORTAL_BASE_PATH mount path; defaults to /portal, cannot be / or under /v1
brand.name LIC_BRAND_NAME vendor name in the portal and messages; defaults to Licenses
brand.tagline LIC_BRAND_TAGLINE headline on the portal sign-in page
brand.logoUrl LIC_BRAND_LOGO_URL https logo shown in the portal header and messages
brand.faviconUrl LIC_BRAND_FAVICON_URL https favicon of the portal
brand.accentColor LIC_BRAND_ACCENT_COLOR #RRGGBB accent for buttons, links and focus rings
brand.supportEmail LIC_BRAND_SUPPORT_EMAIL support address in the portal footer and messages
brand.supportUrl LIC_BRAND_SUPPORT_URL https support page
brand.termsUrl LIC_BRAND_TERMS_URL https terms of service, linked from the portal footer
brand.privacyUrl LIC_BRAND_PRIVACY_URL https privacy policy, linked from the portal footer

The account row is created at startup with generated keys (secret key, RSA-2048, ed25519, ECDSA P-256) that anchor token digests and response signatures.

With cache.addr set, token lookups are cached in Valkey under the account's token generation. Regenerating or revoking a token, and the bulk revocation of password changes, start a new generation, so a changed token is never served from the cache; entries otherwise expire after 30 seconds. Bearers are always read live. Account rows hold the private signing keys and are never cached. Valkey also shares the password-attempt windows between replicas.

POST /v1/accounts/{account}/search follows Keygen: licenses, users, products, policies, groups and entitlements by their searchable attributes, AND or OR combined, answered as the type's list document. Machines are accepted and answer nothing while devices are deferred. Where Keygen fails with a server error (an unknown op, a non-string term for a text attribute, meta of the wrong kind, the EE request-log search), lic answers a 400 in Keygen's error style instead.

Customer portal

Enable the portal on the same HTTP listener as the public API:

http:
  portal:
    enabled: true
    basePath: /portal
brand:
  name: Acme Software
  tagline: All your Acme licenses in one place.
  logoUrl: https://acme.example/logo.svg
  accentColor: "#3366ff"
  supportEmail: support@acme.example
  termsUrl: https://acme.example/terms
  privacyUrl: https://acme.example/privacy

Nothing in the portal or the shipped messages names a particular vendor: brand is the installation's identity. Startup fails on a non-https URL, a color other than #RRGGBB or a malformed support address. Logo and favicon hosts are added to the portal's Content-Security-Policy img-src; everything else stays 'self'. Messages receive the same identity as brand_name, brand_logo_url, brand_accent_color, support_email, support_url and portal_url.

Customers with role user sign in at /portal; operators use the separate Backplane console. The portal has no admin controls and calls only /v1/accounts/{configured-account-id}/..., using the customer's own token. It supports English and Russian, light/dark themes, password recovery, password changes, TOTP setup, customer licenses, and personal API tokens. The shared ui-kit provides standalone controls without the Backplane shell.

Session tokens stay in sessionStorage, not localStorage, so closing the tab ends the browser's stored session. Logout first revokes the token through the public API. Deploy over HTTPS; the portal itself doesn't implement a second authentication backend.

Password reset links must point to /portal/reset#account=<account-id>&user=<user-id>&token=<reset-token> (replace /portal if you configure a different base path). Parameters belong in the URL fragment so HTTP requests and access logs never contain the reset token. The page clears the fragment immediately after loading. Query-string reset credentials are not accepted. The reset form uses the public POST /users/{user-id}/actions/reset-password action with the reset token and new password. The lic.Deliver automation (below) produces these links from automation.portalBase; serving the SPA adds no email endpoint.

License downloads use the public check-out action and save its certificate as a .lic file. Keygen allows a user to check out only their own unprotected licenses; the portal displays permission and API errors rather than bypassing those restrictions. Legacy-encrypted keys that the public API omits cannot be recovered by the portal; it shows an unavailable value and no copy action.

Default customer permissions also omit product.read and policy.read. Product and plan names therefore aren't available through the direct resource or license-relationship read endpoints; include=product,policy on a license read doesn't provide them either. The portal keeps the license list and detail usable, uses the license's own name as the heading, and omits inaccessible product and plan fields. Keys, status, expiry, usage and entitlements remain visible. If the public API permits those relationship reads, the portal displays the names normally.

make build installs frontend dependencies with the frozen Yarn lockfile, builds the SPA, copies it into internal/infra/portal/dist, then compiles Go. The release Dockerfile performs the same build in a Node stage before the Go stage. Like backplane-templates, the repository includes a minimal embed placeholder so plain go build ./... works without Node; this placeholder isn't a usable portal. Run make portal before a manual Go release build. Don't commit generated bundles over the placeholder. If GitHub Packages requires authentication in your build environment, pass an npmrc as a BuildKit secret instead of copying credentials into the image:

docker build --secret id=npmrc,src="$HOME/.npmrc" -f deployments/Dockerfile .

The server serves deep links with the SPA index and injects the configured base path, title, and public account id; it never injects credentials. Index responses use Cache-Control: no-store; hashed Vite assets use public, max-age=31536000, immutable. Missing assets return 404, not HTML. The portal sets a same-origin content security policy and no-referrer so reset-link tokens don't leak through the Referer header.

To run frontend checks:

cd portal
env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy yarn typecheck
env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy yarn test
env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy yarn build

Installation automation

Lic declares the optional lic.Deliver hook. Without a binding, password reset retains Keygen's webhook-only behavior. With the shipped binding, lic calls templates.Render, then courier.SendMail; lic contains no SMTP credentials or message branding. POST /passwords still returns the same 204, and meta.deliver: false suppresses mail without suppressing the webhook.

Mount deployments/wiring into the Backplane platform container and set BACKPLANE_WIRING_DIR to its mount path, for example /etc/backplane/wiring. Mount deployments/templates into backplane-templates and set TEMPLATES_FILES_DIR=/etc/lic-templates to that mount path. Both services reconcile their files automatically; no console import or startup job is needed. Keep unrelated wiring and templates in those directories too, since removing a file removes its managed definition.

Configure lic's public portal link and message language:

automation:
  portalBase: https://licenses.example.com/portal
  locale: en

The environment overrides are LIC_AUTOMATION_PORTAL_BASE and LIC_AUTOMATION_LOCALE. portalBase must identify the public portal, not the API; the reset link appends /reset#account=<uuid>&user=<uuid>&token=<claim>. The fragment stays in the browser, out of server/proxy logs and Referer headers. The portal reads it client-side and submits token as passwordResetToken with newPassword to the existing user reset-password action. Templates include English and Russian; the templates service handles language fallback.

Transactions and retries

The business mutation, Keygen webhook records and automation outbox records commit together. The pg-outbox relay performs hook calls and event publication after commit. A failed delivery retries the same stored message and token, not the password-reset request. The message ID becomes hook.Key, so a lost reply retries the same Backplane execution; binding steps also retain their activity idempotency keys. Delivery remains at-least-once: the configured courier channel must honor its activity key to avoid duplicate external mail. An unbound hook, including a binding removed between Bound and Call, succeeds without mail. Other hook errors remain retryable.

The relay uses the webhook retry budget (16 attempts), then retains a dead record for 30 days for operator investigation. Completed outbox payloads are cleared. Reset URLs contain credentials; restrict database access and don't log delivery payloads. Backplane password-reset events contain user identity only, never the reset token.

Lic declares these stable Backplane event names: lic.UserCreated, lic.PasswordResetRequested, lic.LicenseCreated, lic.LicenseExpiringSoon, lic.LicenseExpired, lic.LicenseRenewed, lic.LicenseSuspended, lic.LicenseRevoked, lic.LicenseValidationFailed and lic.UsageIncremented. Event IDs survive relay retries; consumers must deduplicate by ID. Installations need NATS JetStream for events and Backplane's hook execution transport for mail. A missing transport leaves committed work pending; it never turns a committed password reset into an HTTP delivery error.

Activities

Other services can call these typed activities from wiring:

Activity Input Result
lic.CreateLicense policy_id, optional owner_id, name, key, expiry, metadata license snapshot and key
lic.GetLicense license (UUID or key) license snapshot and key
lic.SuspendLicense / lic.RenewLicense license updated snapshot and key
lic.ValidateKey key, optional product, policy, fingerprints, entitlements valid, code, detail, nullable license
lic.IncrementUsage license, increment updated snapshot and key

These are installation-internal admin operations, not an alternative public authentication API. Restrict who can author wiring and invoke internal activities. The handlers use the same application scenarios as HTTP, so policy checks, key generation and Keygen webhooks still apply.

Mutating activities, including validation touches, commit a durable result receipt with the business write. They take their key from activity.InfoOf(ctx).Key; concurrent attempts serialize under a PostgreSQL transaction lock. Repeating the same key returns the committed result, while reusing it with different input fails permanently. Receipts have no automatic expiry: deleting one permits an old retry to repeat its mutation. They may contain a license key, including the one-time plaintext of a legacy-encrypted key, so treat this table as credential storage. Domain validation errors fail without retry; storage and transport failures remain retryable.

Directories

Path Synopsis
cmd
lic command
lic is the Backplane licensing service: a 1:1 clone of the Keygen CE licensing API (URLs, JSON:API documents, headers, error codes, behavior) so Keygen clients cannot tell the difference.
lic is the Backplane licensing service: a 1:1 clone of the Keygen CE licensing API (URLs, JSON:API documents, headers, error codes, behavior) so Keygen clients cannot tell the difference.
internal
app/access
Group and group-owner resource authorization (GroupPolicy, Groups::GroupOwnerPolicy).
Group and group-owner resource authorization (GroupPolicy, Groups::GroupOwnerPolicy).
app/accounts
Package accounts is the account scenario: the single account's bootstrap and (later) its settings.
Package accounts is the account scenario: the single account's bootstrap and (later) its settings.
app/apperr
Package apperr is the typed application error: everything a handler needs to render a Keygen error document (status, title, detail, code, source pointer/parameter/header, docs link).
Package apperr is the typed application error: everything a handler needs to render a Keygen error document (status, title, detail, code, source pointer/parameter/header, docs link).
app/authn
Package authn is the authentication scenario: every Authorization scheme and the ?token=/?auth= query form, resolved to a principal.
Package authn is the authentication scenario: every Authorization scheme and the ?token=/?auth= query form, resolved to a principal.
app/automation
Package automation records installation automation in the same transaction as Keygen webhooks.
Package automation records installation automation in the same transaction as Keygen webhooks.
app/entitlements
Package entitlements is the entitlement scenario: the account-level CRUD of the features policies and licenses attach.
Package entitlements is the entitlement scenario: the account-level CRUD of the features policies and licenses attach.
app/events
Package events is where scenarios report what happened: a Keygen webhook event ("license.created", "machine.heartbeat.ping", …) with the resource document it carries.
Package events is where scenarios report what happened: a Keygen webhook event ("license.created", "machine.heartbeat.ping", …) with the resource document it carries.
app/groups
Package groups is the group scenario: CRUD, the member relationship indexes and the owners join resource.
Package groups is the group scenario: CRUD, the member relationship indexes and the owners join resource.
app/jobs
Package jobs is the periodic-jobs runner: every tick takes a PG advisory xact lock so exactly one replica runs it, then runs the jobs that are due.
Package jobs is the periodic-jobs runner: every tick takes a PG advisory xact lock so exactly one replica runs it, then runs the jobs that are due.
app/keys
Package keys is the key scenario: the members of pooled policies' key pools (create requires a pooled policy; the pool itself lives under /policies/{id}/pool).
Package keys is the key scenario: the members of pooled policies' key pools (create requires a pooled policy; the pool itself lives under /policies/{id}/pool).
app/licenses
License actions: revoke, renew, suspend, reinstate, check-in and the usage counters.
License actions: revoke, renew, suspend, reinstate, check-in and the usage counters.
app/paging
Package paging parses the three pagination modes (limit, offset, keyset) and the order parameter exactly like the reference, and builds the collection links (with pagination meta nested in links).
Package paging parses the three pagination modes (limit, offset, keyset) and the order parameter exactly like the reference, and builds the collection links (with pagination meta nested in links).
app/passwords
Package passwords is the password-reset request scenario (POST /passwords): always a 204, indistinguishable for unknown emails.
Package passwords is the password-reset request scenario (POST /passwords): always a 204, indistinguishable for unknown emails.
app/policies
Package policies is the policy scenario: CRUD over the full attribute surface, the product/license relationship routes, the key pool and entitlement attach/detach.
Package policies is the policy scenario: CRUD over the full attribute surface, the product/license relationship routes, the key pool and entitlement attach/detach.
app/products
Package products is the product scenario: CRUD, the product's relationship routes (policies, licenses, machines, tokens, users) and product-token generation.
Package products is the product scenario: CRUD, the product's relationship routes (policies, licenses, machines, tokens, users) and product-token generation.
app/search
Package search is POST /search: it validates a typed query, authorizes the bearer, and resolves the matching ids of one resource type.
Package search is POST /search: it validates a typed query, authorizes the bearer, and resolves the matching ids of one resource type.
app/secondfactors
Package secondfactors is the TOTP second-factor scenario: create (secret revealed), enable/disable (OTP-verified), delete, and the index/show reads.
Package secondfactors is the TOTP second-factor scenario: create (secret revealed), enable/disable (OTP-verified), delete, and the index/show reads.
app/tokens
Package tokens is the token scenario: generation (the POST /tokens endpoint).
Package tokens is the token scenario: generation (the POST /tokens endpoint).
app/users
Package users is the user scenario: the bootstrap admin seed, the status computation, CRUD, the password/ban actions and the relationship reads (me/profile included).
Package users is the user scenario: the bootstrap admin seed, the status computation, CRUD, the password/ban actions and the relationship reads (me/profile included).
app/webhooks
Package webhooks is the webhook scenario: endpoint CRUD (the URL, subscription and signature rules), the webhook-events routes (index, show, destroy, retry) and the event publisher that writes the outbox rows inside the caller's transaction.
Package webhooks is the webhook scenario: endpoint CRUD (the URL, subscription and signature rules), the webhook-events routes (index, show, destroy, retry) and the event publisher that writes the outbox rows inside the caller's transaction.
domain
Package domain holds the pure rules of the licensing model: entities, enums, sentinels, key schemes and the expiry/status/heartbeat math.
Package domain holds the pure rules of the licensing model: entities, enums, sentinels, key schemes and the expiry/status/heartbeat math.
domain/crypto
Package crypto holds the key generation and signing helpers: account keys, license-key schemes, license/machine files, response signatures and token secrets/digests.
Package crypto holds the key generation and signing helpers: account keys, license-key schemes, license/machine files, response signatures and token secrets/digests.
domain/jsonapi
Package jsonapi holds the wire-format transforms every handler shares: the metadata key casing (camelCase on the wire, snake_case stored) and the timestamp rendering.
Package jsonapi holds the wire-format transforms every handler shares: the metadata key casing (camelCase on the wire, snake_case stored) and the timestamp rendering.
infra/automation
Package automation declares the service-owned Backplane contracts and dispatches committed outbox records to the SDK.
Package automation declares the service-owned Backplane contracts and dispatches committed outbox records to the SDK.
infra/cache
Package cache holds the cache-aside decorators of storage ports.
Package cache holds the cache-aside decorators of storage ports.
infra/commit
Package commit runs actions after the surrounding transaction commits: effects outside the database (a cache invalidation) must not be observed before the change they describe.
Package commit runs actions after the surrounding transaction commits: effects outside the database (a cache invalidation) must not be observed before the change they describe.
infra/console
Package console bridges the internal operator API to the real HTTP API.
Package console bridges the internal operator API to the real HTTP API.
infra/httpapi
Package httpapi is the HTTP face of the service: the ogen handler (thin: extract params, call one scenario, translate the outcome), JSON:API rendering, the error mapping and the full middleware pipeline (request id, path normalisation, content negotiation, authentication, response signing).
Package httpapi is the HTTP face of the service: the ogen handler (thin: extract params, call one scenario, translate the outcome), JSON:API rendering, the error mapping and the full middleware pipeline (request id, path normalisation, content negotiation, authentication, response signing).
infra/portal
Package portal serves the standalone customer portal, outside the public JSON:API middleware.
Package portal serves the standalone customer portal, outside the public JSON:API middleware.
infra/postgres
The full licenses repository: rows with their policy and owner context, the filtered index, the relationship rows and the actions' writes (internal/app/licenses).
The full licenses repository: rows with their policy and owner context, the filtered index, the relationship rows and the actions' writes (internal/app/licenses).
infra/render
License, machine, license-user/license-entitlement and license-file documents (the reference's LicenseSerializer, MachineSerializer, LicenseUserSerializer, LicenseEntitlementSerializer and LicenseFileSerializer).
License, machine, license-user/license-entitlement and license-file documents (the reference's LicenseSerializer, MachineSerializer, LicenseUserSerializer, LicenseEntitlementSerializer and LicenseFileSerializer).
infra/webhook
Package webhook implements pg-outbox delivery: signed HTTP POSTs with Keygen's outcome classification, URL checks and auto-disable rules.
Package webhook implements pg-outbox delivery: signed HTTP POSTs with Keygen's outcome classification, URL checks and auto-disable rules.
oas
Code generated by ogen, DO NOT EDIT.
Code generated by ogen, DO NOT EDIT.
service
Package service is the composition root: it wires the configuration, the dependencies (PostgreSQL, Valkey) and the scenarios into the tree the Backplane SDK runs.
Package service is the composition root: it wires the configuration, the dependencies (PostgreSQL, Valkey) and the scenarios into the tree the Backplane SDK runs.
testkit
Package testkit holds the helpers tests share: creating the bootstrap admin user and her token directly in the database.
Package testkit holds the helpers tests share: creating the bootstrap admin user and her token directly in the database.
ui
Package ui carries the console module built by make web.
Package ui carries the console module built by make web.
pkg
licensing
Package licensing is an idiomatic Go client for the Keygen licensing API (https://keygen.sh), working both against Keygen CE/EE and any 1:1 compatible implementation.
Package licensing is an idiomatic Go client for the Keygen licensing API (https://keygen.sh), working both against Keygen CE/EE and any 1:1 compatible implementation.
licensing/internal/jsonapi
Package jsonapi is the licensing client's private JSON:API transport: the document envelope, request encoding, and response decoding into the client's typed structs.
Package jsonapi is the licensing client's private JSON:API transport: the document envelope, request encoding, and response decoding into the client's typed structs.
proto
test
conformance
Package conformance provides a differential test harness that replays HTTP scenarios against a Keygen CE-compatible server, records normalised golden transcripts from the live Keygen CE reference, and diffs live runs against them.
Package conformance provides a differential test harness that replays HTTP scenarios against a Keygen CE-compatible server, records normalised golden transcripts from the live Keygen CE reference, and diffs live runs against them.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL