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:
- 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.
- 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
- A customer buys a subscription on the payments server and receives a license key by email: a string they can paste.
- 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. - 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,revokedorexpired. - 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.
- When a paid period ends, the key expires. A renewal arrives as a new key, which is activated the same way.
- 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
PREFIXis the key prefix of the product on the payments server, for examplePB1. A key with another prefix belongs to another product.payloadis a JSON object as base64url without padding.signatureis the Ed25519 signature (64 bytes, base64url without padding) over the ASCII textPREFIX.payload.
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
- The address must be
https://. Plainhttp://is accepted forlocalhost,127.0.0.1and[::1]only. - The license key is removed from every text that came back from the server or the network before it reaches a message.
- Two read-only calls are useful when something looks wrong:
GET {serverUrl}/healthzandGET {serverUrl}/v1/licensing. The extension does not make them.
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
- The server not answering never locks editing at once. The status becomes
grace, counted from the last time the server confirmed the key, forgraceDays(14). - Time spent without the editor running does not count against anyone: nothing is taken away before the server was actually asked and did not answer.
- After the grace period editing locks until the server confirms again. The key stays stored.
5.2 The clock
- Every decision uses the latest time ever observed, not the clock alone. Setting the clock back extends neither a trial nor a grace period, and does not revive an expired key.
- The
Dateheader of anactiveanswer replaces the remembered time, so a clock that was once wrong into the future does not leave a license looking expired for good.
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:
- open any
.pbfile and read everything in it, - run and view reports,
- export.
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:
- The page in VS Code. Every edit from the page is checked.
- VS Code chat tools (
pb_read_board,pb_apply_actions). An edit to a board that is open goes through the editor and is checked there. An edit to a file that is not open is checked by the tool before it writes. - The
pbcommand. An edit sent to an open editor is checked by the editor. An editpbmakes to the file itself is not checked: the command runs outside VS Code, where there is no subscription to ask about, and the file is the user’s own data.
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
- A setting the user can edit is not an enforcement boundary. Anyone can empty
pb.license.publicKeyand be back todisabled, or point the settings at their own server and key. For a release that is meant to enforce, the public key and the prefix have to be built-in values, with the settings honoured in development builds only, or removed. This is a decision for the maintainer and is not made in the client. - The trial lives in the extension’s local storage. Wiping that storage starts a new trial.
- Check the configured key and prefix with one real license key from the server before publishing. A wrong public key shows as “the signature of the license key is not valid” on activation.
- All of this stops honest customers from forgetting to renew. It does not stop someone who edits the extension’s JavaScript, and nothing shipped as source can.
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”:
- No activation or sign-in against the real server has been made. The client was written from the server’s protocol and tested against
referenceServer.ts; onlyGET /healthzandGET /v1/licensingof the real server were called. - The prices in
plans.jsonare the same as those of Browser DevTools; after a change,node scripts/connect-platform.mjs --syncpushes them to the platform. - Recovering a lost key, usage credits, the kill switch (
/v1/recover,/v1/usage,GET /v1/licensing) are not used.