پرش به محتوا

The license

Pro: the license file, its states and verbs, the re-check and offline grace, source mode, deactivation, and what a refund does.

مستندات به انگلیسی نوشته شده است. بقیهٔ سایت به فارسی خوانده می‌شود.

The license — what a console holds, and how it gets it

Phase Console Pro is sold as a license key. This page is the reference for the person who runs or maintains a console: the file the license lives in, the seven states, every verb that reads or moves it, the weekly re-check and the offline grace, and what a deactivation, a refund or the end of a term does. The in-app guide's License section tells the same story from Settings ▸ License. What you control has the short command reference, and Journal events holds a row for every line the license writes to the log. Install gets the package onto a machine.

The numbers a buyer is promised are written in the end user license agreement, LICENSE, and owned once in code, by viewer/shared/license-model.js (LICENSE_TERMS). viewer/test/license-files.test.ts reads both and fails on any number that differs.

TermValue
Machines per key3, at a time
Paid term365 days from purchase; a renewal extends it
Re-checkat least every 7 days while a console runs
Offline grace30 days, from the moment a check falls due
KeyPCP-XXXX-XXXX-XXXX-XXXX

What holds a license

Only the prebuilt phase-console-pro package reads one.

EditionLicense codeWhat the console reads
FreenoneNothing. The Pro surfaces are not in the tree.
Pro packageviewer/server/license/license.json on this machine: the signed file the license server returned.
Sourceviewer/server/license/The tree itself. See Source mode, and test mode.

The free features keep working in every state of the license. A closed license costs a console its Pro surfaces and nothing else.

The license file

One file per machine, shared by every console on it, because the license counts machines and not projects:

$XDG_STATE_HOME/phase-console/license.json      # ~/.local/state/phase-console/license.json when it is unset

It is written atomically (a temp file, then a rename) with mode 0600, by the console and by the CLI alike. viewer/server/license/store.ts owns it. The record holds:

FieldHolds
v1
fileThe signed license file, exactly as the license server returned it.
activationIdThe place this machine holds on the key, or null.
answerWhat the license server last said: active (a fresh file), or a 402 expired or revoked.
answeredAtWhen it said so (ISO).
hwmThe high-water mark: the latest moment this machine has been seen at (ISO).

The signed file is {kid, payload, sig}. sig is the base64url Ed25519 signature of the payload's canonical JSON (keys sorted, no whitespace). The payload holds v (1), licenseId, keyId, tier (pro), emailHash, machine.fingerprint, issuedAt, expiresAt, updatesUntil, recheckDays, graceDays and features. recheckDays and graceDays set the two clocks, and when a file lacks them the terms' own 7 and 30 stand in.

viewer/server/license/verify.ts checks the signature with Node's own Ed25519 against the raw public key pinned under the file's kid in viewer/shared/license-keys.js (today one key, 63ee5e94). The console never signs. The file is verified again at every read, so editing it makes the license invalid and never makes it last longer. A file fails as malformed (not a file, a signature of the wrong shape, or a payload this console cannot read), kid (signed by a key the console does not pin) or signature. A fourth reason, machine, is the console's own: the file verifies, and names another machine.

Which machine this is. The fingerprint is the sha256 hex of <platform id>\n<host label> (viewer/server/license/fingerprint.ts). The platform id is IOPlatformUUID on macOS, read with ioreg and asked twice; /etc/machine-id, else /var/lib/dbus/machine-id, on Linux and WSL2; and on anything else, or when the id cannot be read, the host label alone. Each WSL2 distribution has its own /etc/machine-id, so two distributions on one PC are two machines. The host label is the first label of the host name, lower-cased. On macOS it is scutil --get LocalHostName, because a DHCP server can rename os.hostname() from one network to the next, and a fingerprint that followed it would read every license file as another machine's. Renaming a machine still makes it a new one. Only the hash leaves the machine, beside the host label and platform that activation also sends so the account page can name it.

The high-water mark. The console advances hwm whenever its clock passes it by more than ten minutes. A clock that reads more than ten minutes before it makes the state invalid (why: clock), so a clock rolled back buys no offline time. It clears when the clock catches up, or when the license server answers with a file whose issuedAt agrees with this clock, which vouches for the moment.

The states

licenseState() in viewer/shared/license-model.js is a pure function from a reading and a moment to one of seven words, and it touches no clock, file or key. source is never its answer: a source checkout is recognised by the tree before any file is read.

StatePro surfaceswhyMeaning
sourceanswersource-treeA git checkout of the private repository. No file is read and no key is asked for.
activeanswerchecked, fallbackA verified file, checked within recheckDays. fallback: the term has ended and this build is still covered.
graceanswerrecheck-dueA check is overdue and the license server could not be reached. Pro works until graceEndsAt.
expiredrefusedgrace-ended, term-endedThe grace ran out, or the term ended and this build is newer than updatesUntil.
revokedrefusedrevokedThe license server answered 402 revoked: a refund, a dispute, or the seller's revoke.
unlicensedrefusedno-fileNo license file. The free features and the activation surface.
invalidrefusedmalformed, kid, signature, machine, clockThe file does not verify, names another machine, or the clock reads behind the high-water mark.

The first three are LICENSE_OPEN_STATES. Anywhere a Pro surface answers, it answers in those and in no other. The same object reaches a page three ways, as state.license on GET /api/state, as the answer of GET /api/license, and as the payload of the license:state event, sent whenever the state or its why moves. It holds state, why, open, mode (source, package or test), licenseId, keyId, activationId, issuedAt, answeredAt, recheckAt, graceEndsAt, expiresAt, updatesUntil, fallback, releasedAt, version, server, path, errand and next. A computed status is reused for 30 seconds, or until the file changes.

The verbs

On the command line, from bin/phase-console.mjs:

CommandWhat it doesExit
phase-console license activate <key>Reads the key offline and refuses a typo before any request, then asks the license server for this machine's place and stores the signed file. At the limit it names the machines that hold the key.0 done · 1 refused · 2 usage
phase-console license status [--json]The state and its dates. When a check is wanted (it is due, or a check could change the state) it asks the license server first. --json prints the status object.0 while Pro answers · 1 otherwise
phase-console license deactivate [--local]Frees this machine's place on the license server, then removes the file. --local removes the file without asking.0 · 1 when the server could not free the place
phase-console license gate <verb or flag>A script's question, not a person's: is this word allowed on this machine? viewer/run asks it before an agent flag (--install-agent, --uninstall-agent or --agent-*) reaches viewer/deploy/agent.sh.0 allowed · 2 refused, with the errand

A key reads the way Crockford's base 32 reads: case, hyphens and spaces do not matter, I and L read as 1, O as 0, and U is no symbol. The last block is the twelve body symbols read as one number, modulo 1,048,573 (the largest prime below 2^20), so the CLI and the console's key field refuse a typo with no request. Activating on a source checkout stores the file and says the checkout holds every verb whatever the license says.

Over HTTP, on the console, in every state and never gated:

RouteBodyAnswer
GET /api/licensenone200 with the status object. HEAD too.
POST /api/license/activate{"key": "PCP-…"}200 {ok, created, license}; created is false when this machine already held the key.
POST /api/license/refreshnone200 {ok, answer, license}; answer is active, expired, revoked or unlicensed.
POST /api/license/deactivate{"local": true}, optional200 {ok, removed, local, license}.

The three POSTs carry the console header (x-phase-console: 1) on a same-origin request and need no capability flag: activating the license a buyer paid for is not a capability to switch on. Without the header they answer 403. A refusal is {ok: false, reason, message, license, …}, with the status its reason gives:

reasonStatus
key-format, key-checksum, invalid-key, bad-request400
refused402
unknown-key404
limit, no-license409
rate-limited429
signing-unavailable503
anything else: unreachable, invalid-file, unknown-license502

limit carries maxActivations and activations, the machines holding the key. rate-limited carries retryAfterSeconds.

The weekly re-check and the offline grace

The license server is asked again when a check falls due. licenseState() reads three moments off the file. The last check is the later of the file's issuedAt (when the site signed it) and, after an expired answer only, answeredAt. recheckAt is the last check plus recheckDays. graceEndsAt is recheckAt plus graceDays. So Pro works offline for up to 37 days after the last successful check. The rules run in this order, and the first to match answers:

  1. No record: unlicensed. A record the console cannot read, or a file that fails: invalid.
  2. The clock more than ten minutes behind the high-water mark: invalid (clock).
  3. The last answer was revoked: revoked.
  4. graceEndsAt has passed: expired (grace-ended). This holds inside the perpetual fallback too.
  5. The term has ended (the last answer was expired, or expiresAt has passed) and this build is not covered: expired (term-ended). A covered build goes on to the next rule with the fallback in use.
  6. recheckAt has passed: grace (recheck-due).
  7. Otherwise active: why is fallback when the fallback is in use, and checked when it is not.

The clock. viewer/server/license/recheck.ts runs once an hour, with a first look 15 seconds after the console starts, and never on a source tree. Each tick asks refreshWanted. The answer is no without a verified file's licenseId, no for invalid unless the reason is clock, yes once recheckAt has passed, and yes whenever the state is not active, so a renewal, a reversed revocation or a corrected clock is picked up within the hour. It is never asked twice within an hour of the last attempt, whatever that answered. One refresh runs at a time, and a second call joins it. A request times out after 20 seconds.

A check sends the license id and the machine's fingerprint, and nothing from a repository, plan or session (EULA §4). What the answer does:

The license server answersThe console
200 with a signed fileVerifies it, checks that it names this machine, stores it in place of the old one, and reads the state again from it.
402 revokedKeeps the file, records the answer and when it came. The state is revoked.
402 expiredKeeps the file and records the answer, which marks the term as ended. A covered build stays active on the fallback; any other reads expired.
402 unlicensedThe machine was freed on the account page. Removes the file. The state is unlicensed.
404, 429, 503, a timeout, no network, or a file it will not acceptChanges nothing. The grace keeps counting, and the clock asks again in an hour.

The term and the perpetual fallback

expiresAt ends the paid term; a renewal moves it. updatesUntil is the last release date the license covers for good, and a renewal moves it with the term. After the term, a build released on or before updatesUntil stays covered (active, with why: fallback), and a later build reads expired (term-ended). A build's release date is releasedAt in its package.json, which scripts/build-pro-package.mjs stamps with the build commit's date, else the date the CHANGELOG.md gives its version. A build whose release date is unknown is not covered. Going back to a version released on or before updatesUntil opens a license whose term ended, and renewing covers every version released within the new term.

Source mode, and test mode

A tree is a source checkout when it runs unbundled and holds both free/manifest.json and scripts/build-free-tree.mjs, the two files the package never ships. Either alone is not enough. A bundle is never one: scripts/build-pro-package.mjs builds every module with the define PACKAGE_BUILD, so the package's edition is fixed when it is built, and no file that appears beside an install — both markers, a .git, anything — makes it a source checkout (control-tower phase 113, #197). A source checkout reads source with why: source-tree, reads no license file, starts no re-check clock, and is refused nothing. It writes license.source at boot and nothing else. That is how the operator's own consoles and source-access buyers run, under LICENSE-SOURCE-ACCESS.md.

Test mode boots a source checkout the way the package would run. PHASE_CONSOLE_LICENSE_KID names a key in viewer/test/license-keys.json. It is honoured on a source tree only, it turns source mode off, and it makes that one test key trusted beside the production key. A suite can then boot the tree unlicensed, or licensed by a file the test key signed. The test key is never pinned in viewer/shared/license-keys.js, and .github/scripts/assert-pro-tarball.sh refuses a package that carries its private half. A rehearsal build (scripts/build-pro-package.mjs --trust-key) pins one more public key and is refused for release.

Deactivating, and freeing a machine

phase-console license deactivate, or Deactivate this machine on Settings ▸ License, asks the license server to free this machine's place. A 204, or a 404 saying the server does not know the license, is enough: the file is removed and the state is unlicensed. If the server cannot be reached, or answers 429, nothing is removed and the message says so: The license server at … could not free this machine, so the license file was kept. The console's button always asks the server. --local, and {"local": true} over HTTP, remove the file without asking, for a machine already freed on the account page.

A machine freed on the account page learns of it at its next re-check: the site answers 402 unlicensed, the console removes the file, and license.deactivated is logged. Activating the same key on another machine needs a free place. A fourth activation is refused: the license server answers 409, and the console reports limit with the machines that hold the key.

What a refund does

The license server revokes a license when the order that issued it is refunded, charged back or disputed, or when the seller revokes it (EULA §7). Nothing on the machine moves until the console asks. At its next check the server answers 402 revoked. The console keeps the file with answer: revoked, reads revoked, logs license.revoked, and every gated surface answers 402. The free features carry on, and so does a run that is going. While the state is revoked, the clock asks again every hour, so a reversed revocation opens the license within the hour. Activating the same key is refused: The license server refused this key: it is revoked. Another key opens it.

A machine that cannot reach the license server learns nothing: it keeps its state, and its grace runs down as usual. A refund of a renewal is not a refund of the license. The license server takes back only that renewal's time, the license returns to its earlier term and is not revoked, and a check afterwards leaves the console active on that term.

What the gate refuses

While the state is outside source, active and grace, viewer/server/license/gate.ts refuses what the gated object of free/manifest.json names, and nothing else:

ListRefusal
routes: METHOD /path, where * is any method, :name one segment and a trailing /* the path and everything under it402 {error: "license", state, next}, with Cache-Control: no-store.
flags and verbs: the CLI'sThe CLI exits 2 before anything runs, printing the errand: phase-console: '<word>' is part of Phase Console Pro. and its need and how.
sse: event families/events does not send them, replayed or live.

The license's own surface is never gated: the four /api/license routes, the license:state event and the license verb. phase-console start under a closed license runs the console in the foreground, as the free edition's does. The acts of phase-console run, and phase-console supervisor set, ask the license themselves, row by row (bin/run-verb.mjs, bin/supervisor-verb.mjs).

On a source tree the gate reads free/manifest.json. The package ships no free/: its build compiles the same object into the bundle (the define PACKAGE_GATED), and the bundle's gate reads no file, so a manifest planted in an install, or its own gated.json emptied or deleted, takes nothing out of the gate. The gated.json beside the license module is a copy for reading. A package built without the lists logs license.gate-missing at boot and refuses nothing, which is a build defect.

Only what the manifest names is gated. Pro behaviour inside a free route or event is not: the conflict radar's repo:radar event comes from a Pro region but is named by free client code, so an unlicensed package keeps computing it. A new Pro route needs a // gated: METHOD /path tag in viewer/server/api/routes.ts and a gated.routes entry, and a Pro event family the client declares needs gated.sse. viewer/test/license-gate-parity.test.ts fails otherwise, in both directions.

The license errand

A closed license raises an errand, {kind: "license", state, need, how}: what is wrong on this machine, and the one command or page that moves it. It is an errand and never a halt, and nothing queues it for a person. It rides state.license.errand, the CLI's refusal, and the next sentence of every 402, which is its how. The header chip, the strip above the Runs home's groups, the lock card and Settings ▸ License draw it. viewer/shared/license-model.js owns its kind, the license:state event and the license verb. <server> is the license server's origin.

State (why)needhow
unlicensedThis machine holds no Phase Console Pro license.Activate one with: phase-console license activate PCP-XXXX-XXXX-XXXX-XXXX — or buy one at <server>/pricing. The free features keep working without it.
expired (term-ended)The license on this machine covers releases up to <date>, and this version is newer.Renew at <server>/account, then run: phase-console license status
expired (grace-ended)This machine has not reached the license server, and its offline grace ran out on <date>.Connect this machine to the internet, then run: phase-console license status
revokedThe license on this machine was revoked.Activate another key with: phase-console license activate PCP-XXXX-XXXX-XXXX-XXXX — or see <server>/account.
invalid (clock)This machine's clock reads earlier than the last time its license was seen.Set the date and time automatically, then run: phase-console license status
invalid (any other)The license file on this machine does not verify (<why>).Activate your key again with: phase-console license activate PCP-XXXX-XXXX-XXXX-XXXX

Log events

viewer/server/license/log.ts writes these to the console's own log, never to a run's journal: a license belongs to the machine, not to a run. The console writes one when it notices a move, at boot, at a re-check, or at the first read after phase-console license … changed the file. The CLI answers on its own terminal and writes none. A line carries the state, why, the mode and whichever of keyId, licenseId, recheckAt, graceEndsAt, expiresAt and fallback the license has. A move also carries from (the state it left) or at: "boot", and license.check-failed carries its reason. The full rows are in Journal events.

EventWhen
license.sourceAt boot, from a source checkout.
license.gate-missingAt boot, from a package that carries no gated lists.
license.activatedThe license opened: a key was activated, a re-check brought it back, or the console booted holding a checked one.
license.refreshedA re-check stored a fresh signed file.
license.graceA check fell due and is unanswered; the license is inside its grace.
license.expiredThe grace ran out, or the term ended for a build the license no longer covers.
license.revokedA re-check was answered 402 revoked.
license.invalidThe file does not verify, or the clock reads behind the high-water mark.
license.deactivatedThe license closed to unlicensed: a deactivation, or an account-page release learned at a re-check.
license.check-failedA re-check could not be answered. Nothing on disk changed.

Environment

VariableDoes
PHASE_CONSOLE_LICENSE_SERVERThe license server's origin, https://phase-console-site.vercel.app unless set. It must be an http or https URL, and trailing slashes are dropped. Activation, the re-check and deactivation all ask it. A file is trusted for its signature and never for where it came from, so pointing this elsewhere can deny a machine its license but never grant one.
PHASE_CONSOLE_LICENSE_KIDTest mode, on a source checkout only: the eight-hex kid of a key in viewer/test/license-keys.json.
XDG_STATE_HOMEWhere license.json lives, under phase-console/.

Where the code is

PathHolds
viewer/shared/license-model.jsLICENSE_TERMS, the seven states, the key grammar and licenseState(). No imports.
viewer/shared/license-keys.jsThe pinned public keys.
viewer/server/license/index.tsLicenseRuntime: read, verify, activate, refresh, deactivate. Source, package and test mode.
viewer/server/license/store.ts the file, verify.ts the signature, fingerprint.ts the machine, activate.ts the license server's three routes, recheck.ts the clock, gate.ts the refusals and the errand, log.ts the log lines, env.ts the two variables.
viewer/server/api/routes.tsThe four /api/license routes, the /events filter and the gate's tags.
bin/phase-console.mjsThe license verb, and the CLI's own gate on the words the manifest names.
viewer/client/src/data/license.tsThe four /api/license routes as the client speaks them, off first paint.
viewer/client/src/screens/settings/license.tsxSettings ▸ License.
viewer/client/src/cards/pro/license-lock.tsx, license-lock-card.tsxThe lock card, and its lazy door.
viewer/client/src/shell/pro/license-chip.tsx, license-words.tsThe header chip, and the words every license surface shares.
viewer/client/src/screens/runs/license-strip.tsxThe strip above the Runs home's groups, only while the license asks for something.