Project Board: Subscription Licensing

How the extension checks that a subscription is paid, against the Soft Forge Payments server.

   
Client src/licensing/ (unit tested against a fake of the server), with the platform’s sign-in client vendored in src/third-party/licensing-client/
Terms Free plan (read, report, export) and paid plans (editing), after a 30-minute trial with everything available
Server Soft Forge Payments server at https://02.dev.soft-forge.dev/payments
Enforcement On: the build is connected to the product “Project Board” (key prefix PB) on the platform (section 7)
Date 2026-10-04

Section 0 says what is sold. Sections 1 to 3 describe what travels between the extension and the server. Sections 4 to 6 say what the extension decides. Sections 7 and 8 are for the maintainer who switches enforcement on. Section 9 lists what is not done.


0. Plans, the trial and the Free plan

Project Board is published under free and licensed terms:

  Reading, reports, export Editing
Trial: the first 30 minutes after the first use yes yes
Free plan: after the trial, without a subscription yes no
Pro, Pro yearly, Team: a subscription yes yes

The paid plans are listed in plans.json (price in cents, period, seats, how long a sign-in lasts without the platform) and are created on the platform from that file (section 7.2). A plan is bought in the client portal of the platform.

There are two ways to bring a plan to an installation, asked in this order:

  1. Sign in (the main way). “Sign in” opens the platform in the browser; the person allows the request there, comparing the code both sides show. The installation then holds short session keys for every plan of the account, bound to this machine, and renews them by itself. Signing out, here or in the portal, frees the seat.
  2. A license key pasted from an email (sections 1 to 3).

Without either, the trial applies, and after it the Free plan. The software is proprietary; see the LICENSE file.

1. The model in plain words

  1. A customer buys a subscription on the payments server and receives a license key by email: a string they can paste.
  2. The key is a signed document. It says who paid (sub), for which plan (tier) and until when (exp). The extension checks the signature offline, with the public key of the product.
  3. On activation the extension also asks the server, with the key and its machine id. The server takes one seat for that machine and answers active, revoked or expired.
  4. The extension stores the key in the editor’s secret storage. About once a day it asks the server again. That is how a cancellation, a revocation or a removed seat reaches it.
  5. When a paid period ends, the key expires. A renewal arrives as a new key, which is activated the same way.
  6. Seats are freed in the subscription portal of the payments server. There is no call a client can make to give a seat back.

Only editing depends on any of this. Opening a board, reading it, running reports and exporting always work (section 6).

The server never calls the extension, and the extension never needs the server to open a board. If the server cannot be reached, nobody who paid loses anything until a grace period has run out.

2. The license key

PREFIX.payload.signature

2.1 The claims

Claim Type Meaning
sub string, required The customer’s email.
tier string, required The plan: pro, team, …
id string The server’s id of the license.
iat number Issued at, unix seconds.
exp number Unix seconds. The key is worthless after it. It is the end of the paid period plus the server’s key grace days.
seats number How many machines may hold the license at once.
kid string Which signing key of the product signed this key. The server uses it; the client tries every public key it knows.

The extension keeps sub, tier, id, iat, exp and seats and ignores everything else, so the server can add claims.

2.2 A worked example

The key pair below is the public test vector of RFC 8032 (section 7.1, test 1). It is public knowledge: never use it for anything real.

private seed (hex)   9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60
public key (base64)  11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=
prefix               PB1

The claims, serialized on one line (iat is 2026-10-01 09:00 UTC, exp 2026-11-01 09:00 UTC):

{"sub":"ada@example.com","tier":"pro","id":"lic_7Q2M4K","iat":1790845200,"exp":1793523600,"seats":3,"kid":"k1"}

The license key:

PB1.eyJzdWIiOiJhZGFAZXhhbXBsZS5jb20iLCJ0aWVyIjoicHJvIiwiaWQiOiJsaWNfN1EyTTRLIiwiaWF0IjoxNzkwODQ1MjAwLCJleHAiOjE3OTM1MjM2MDAsInNlYXRzIjozLCJraWQiOiJrMSJ9.-QaIvpDsOO-x_oJwa8l_MLfC_qCsN56CUcS6A2prn_zjk7tM79vPwde-NPavxuk6KtdzWquFF9QaMegAilVRBg

Ed25519 signatures are deterministic, so any correct implementation produces exactly this key from this seed and this JSON. A unit test (licensing: documentation) signs the example again and fails if this section stops being true.

2.3 How the client verifies (verifyKey)

In this order, the same as every Soft Forge Payments client:

Check LicenseError.code when it fails
Three parts separated by dots, and the first is the configured prefix malformed-key
payload and signature are base64url, and payload is a JSON object corrupt-key
The signature is 64 bytes and verifies with one of the configured public keys bad-signature
sub is a non-empty string and tier is a string missing-fields

Expiry is not part of verifyKey: the manager judges exp with its own guarded clock (section 5.2). A public key that cannot be read is invalid-key. No message of a LicenseError contains a key.

3. The server call

The extension makes one call. Everything else on the server (checkout, the portal, sign-in, usage, administration) is not used by it.

3.1 POST {serverUrl}/v1/activate

With the shipped address that is https://02.dev.soft-forge.dev/payments/v1/activate.

{ "key": "PB1.eyJ...", "machineId": "3f9c0d7e5a1b4c2d8e6f0a1b2c3d4e5f", "version": "0.10.0" }
Field Meaning
key The license key.
machineId Stable for this installation: 32 hex characters derived from the editor’s machine id and a random value, made once. The server keeps at most 128 characters.
version The version of the extension. The server keeps at most 32 characters.

The same call is the activation and the daily check: a machine that already holds a seat does not take a second one.

The answer is always JSON with a status:

{ "status": "active", "reason": "active", "seats": 3, "seatsUsed": 1, "renewsAt": 1793523600, "cancelAtPeriodEnd": false }
status Meaning Reasons seen
active The key is paid and this machine holds a seat. active, complimentary license
expired The paid period is over. token exp passed
revoked The key is not accepted. unknown product, unknown signing key, bad key: ..., unknown license, revoked in store, vendor suspended, seat limit exceeded (n/m)

renewsAt is unix seconds. seats, seatsUsed, renewsAt and cancelAtPeriodEnd are optional.

A verdict is a decision, whatever the HTTP status. Everything else is no answer: a network failure, a timeout (15 seconds), a body that is not JSON, JSON without one of the three statuses. No answer never takes anything away at once (section 5.1).

3.2 Rules of the client

3.3 The reference server

src/licensing/referenceServer.ts is an in-memory stand-in for POST /v1/activate, used by the unit tests. It is not a real server and the extension never calls it. createReferenceServer({ privateKey, prefix, licenses, now }) returns an HttpClient with keyFor(licenseId), which signs the key the service would mail.

To try the whole flow over real HTTP on one machine:

// node dev-license-server.js   (after `npm run compile`)
const http = require('http');
const { generateKeyPairSync } = require('crypto');
const { createReferenceServer } = require('./out/licensing');

const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const handle = createReferenceServer({
	privateKey,
	prefix: 'PB1',
	licenses: [{ licenseId: 'lic_dev', tier: 'pro', email: 'dev@example.com', paidUntil: new Date(Date.now() + 30 * 86400000).toISOString(), seats: 1, machines: [] }]
});

http.createServer((request, response) => {
	let body = '';
	request.on('data', (chunk) => { body += chunk; });
	request.on('end', async () => {
		const answer = await handle({ method: request.method, url: `http://localhost:8787${request.url}`, body });
		response.writeHead(answer.status, answer.headers);
		response.end(answer.body);
	});
}).listen(8787, () => {
	console.log('pb.license.serverUrl  http://localhost:8787');
	console.log('pb.license.publicKey ', publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('base64'));
	console.log('pb.license.keyPrefix  PB1');
	console.log('license key          ', handle.keyFor('lic_dev'));
});

4. What the client decides

LicenseManager.status() reads the store and the clock, never the network.

kind canEdit When
disabled yes The public key or the key prefix (or the server address) is not configured. Nothing is recorded.
trial yes No plan is held and the trial (30 minutes from the first status) is running. minutesLeft and endsAt say how long.
active yes The signed-in account has a plan with a session key that has not ended; or a key is stored, it has not expired, and the server did not refuse it.
grace yes As active, but the last time the server was asked it gave no answer, and the last confirmation is less than 14 days old.
expired no The key’s exp has passed, or the server answered expired, or the server gave no answer for longer than the grace period.
unlicensed no The Free plan: no plan and the trial is over (also when signed in to an account without a plan, or when its session keys ended), or the server answered revoked (the reason is in the message).
invalid no The stored key cannot be verified by this build, or the configured public key cannot be read.

What each call does:

Call Does
activate(key) Checks the key offline. A key that is wrong or already expired is never sent. Then asks the server. The key is stored only when the answer is active; anything else changes nothing that is stored, so a mistyped key does not end a trial.
refresh() Asks the server about the stored key when the last attempt is a day old, when it was refused, or when it got no answer. refresh(true) always asks. revoked and expired lock editing but keep the key, so a renewed subscription or a freed seat is picked up by the next check.
deactivate() Forgets the key and the last answer on this installation. No request is made; the message says the seat is freed in the subscription portal.
startSignIn(deviceName) Asks the platform for a sign-in and returns the code, the page to open and finish(signal), which waits for the person’s decision: approved, denied, expired, cancelled or error. Only approved stores anything. Available when canSignIn (the build knows its product).
signOut() Signs the installation out on the platform, which frees its seat, and forgets the sign-in. A stored license key stays.

A plan of the signed-in account comes first, then a stored key, then the trial, then the Free plan. refresh() also renews the session keys of the account when they are due (refresh(true) always); a sign-out made in the portal is noticed there. While the platform cannot be reached, the session keys keep working until they end (sessionDays and graceDays of the plan), shown as grace.

activate, refresh, deactivate and signOut run one at a time. LicenseStatus.entitlement carries what is shown: licenseId, plan, email, expiresAt, seats, seatsUsed, cancelAtPeriodEnd, and via: 'account' when the plan comes from the sign-in. LicenseStatus.account is who is signed in, with or without a plan.

5. Offline and clock rules

5.1 Offline

5.2 The clock

5.3 What is stored

Key Content Secret
pb.license.key The license key yes (SecretStorage)
pb.license.session The sign-in: its grant, the account and the session keys yes (SecretStorage)
pb.license.check The last answer of the server: status, reason, seats, renewsAt, when it was confirmed and last attempted no
pb.license.trialStartedAt When the trial started no
pb.license.lastSeen The latest time observed no

The keys are exported as LICENSE_STORE_KEYS. Nothing about licensing is ever written into a .pb file.

6. What is deliberately not gated

Without a valid subscription a user can still:

A .pb file is the user’s data in a plain, documented format. Licensing must never stand between a user and their own data, and a lapsed subscription must never make a board unreadable. Only editing is gated, through the single flag LicenseStatus.canEdit.

The licensing module itself gates nothing. It answers the question; the editor asks it before it applies an edit.

Where the question is asked:

7. Configuration

A build knows its product from src/licensing/productConfig.ts, which scripts/connect-platform.mjs writes (section 7.2): the address of the platform, the product id, the key prefix, the public key and the client portal. All of it is public. Until that file is filled, nothing is enforced and there is nothing to sign in to.

PROJECT_BOARD_PLATFORM in the environment replaces it for development: the same JSON (or a path to a file with it), or none for an unconnected build.

The settings replace single values, and an empty setting leaves what the build has:

Setting Default Value
pb.license.serverUrl empty Another payments server, with its base path. The built-in one is https://02.dev.soft-forge.dev/payments.
pb.license.publicKey empty The public key of the Project Board product on the server: base64 of the 32 raw bytes, or the whole PEM block (line breaks may be lost or written as \n; both are read). Several keys separated by commas are all accepted, which is how a key rotation is survived.
pb.license.keyPrefix empty The key prefix of the product: the text before the first dot of every license key.

Nothing is enforced while the public key or the prefix is empty, so every build is fully usable until then. The defaults are DEFAULT_LICENSE_CONFIG; trialMinutes (30) and graceDays (14, for pasted keys) are fixed in code.

7.1 Before switching enforcement on

7.2 Connecting the product

Done once by the owner of the business on the platform, from their machine:

node scripts/connect-platform.mjs --platform https://02.dev.soft-forge.dev/payments

The script opens the platform’s /connect/ page; the owner signs in, picks the business and clicks Allow. The platform creates the product “Project Board” (key prefix PB, its own signing key) and one plan per entry of plans.json. The script then writes src/licensing/productConfig.ts, points the sponsor link of package.json at the client portal, and keeps the app key in .platform/app.json (mode 600, ignored by git and by the package). The next build enforces.

node scripts/connect-platform.mjs --sync pushes plans.json again after a change of prices or plans. --out FILE writes the product as JSON instead (the shape PROJECT_BOARD_PLATFORM reads) and touches nothing else.

7.3 The vendored client

src/third-party/licensing-client/ is the platform’s own client (LicenseClient, Session), copied by scripts/vendor-licensing-client.sh and never edited here; src/third-party/VERSIONS records the revision. LicenseManager uses it for the sign-in and keeps its own code for pasted keys, the trial and the clock rules.

8. The client API in one place

import { DEFAULT_LICENSE_CONFIG, LicenseManager } from './licensing';

const manager = new LicenseManager(
	{ ...DEFAULT_LICENSE_CONFIG, publicKey, keyPrefix, appVersion },   // from the settings
	store,          // LicenseStore: get/set/delete of strings
	nodeHttpClient, // src/connectors/http.ts
	deviceId        // stable for this installation; sent as machineId
);

await manager.status();          // offline; call it as often as needed
await manager.activate(key);     // LicenseStatus; kind 'active' on success
await manager.refresh();         // on start and every few hours; cheap when nothing is due
await manager.refresh(true);     // "check my subscription now"
await manager.deactivate();

if (manager.canSignIn) {
	const started = await manager.startSignIn('VS Code on my-laptop');  // show started.userCode, open started.verificationUrl
	const outcome = await started.finish(abortSignal);                 // { result: 'approved' | 'denied' | …, status }
}
await manager.signOut();

status, activate, refresh, deactivate and signOut never throw for anything the server or the network does; the result is always a LicenseStatus. They do reject if the store itself fails.

verifyKey and signKey throw LicenseError. The extension never signs; signKey exists for the tests and the reference server.

9. Not done yet

Tracked as board issue #35, “Soft Forge Payments Service - Contract, Endpoint and Production Keys”: