@aikdna/kdna-activation-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 KDNA Authors
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,14 @@
1
+ kdna-activation-server
2
+ Copyright 2026 KDNA Authors
3
+
4
+ This product includes software developed at
5
+ https://github.com/aikdna/kdna-activation-server
6
+
7
+ This server is a license-management reference implementation. It
8
+ implements specs/kdna-entitlement-api.md and the self-hosting
9
+ invariant from docs/REMOTE_MODE.md. The server has no KDNA Inc.
10
+ URL hardcoded; license records and the signing keypair are
11
+ deployer-controlled.
12
+
13
+ See LICENSE for the full Apache 2.0 license text. See README.md
14
+ for the self-hosting guide and the API contract.
package/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # @aikdna/kdna-activation-server
2
+
3
+ **Self-hostable HTTP activation server for KDNA `licensed`-mode assets.**
4
+
5
+ This server answers one question:
6
+
7
+ > Is this user / device / organisation currently entitled to use this
8
+ > asset?
9
+
10
+ It implements the four endpoints in
11
+ [`specs/kdna-entitlement-api.md`][1] and the self-hosting invariant
12
+ from [`docs/REMOTE_MODE.md`][2]. Reference implementation of
13
+ roadmap-2026.md Story 24.
14
+
15
+ [1]: https://github.com/aikdna/kdna/blob/main/specs/kdna-entitlement-api.md
16
+ [2]: https://github.com/aikdna/kdna/blob/main/docs/REMOTE_MODE.md
17
+
18
+ ---
19
+
20
+ ## Self-hosting is the default
21
+
22
+ > The KDNA protocol MUST NOT assume a single official KDNA
23
+ > server. Any asset creator can run their own activation server.
24
+ > Official KDNA hosting is one deployment option, not the
25
+ > protocol requirement.
26
+
27
+ This server is the deployer's own. The protocol does not
28
+ hardcode any KDNA Inc. URL. The admin token is deployer-
29
+ controlled. License records are deployer-controlled. The
30
+ server's signing keypair is generated on first start and
31
+ stored locally.
32
+
33
+ ---
34
+
35
+ ## Quick start (self-hosting)
36
+
37
+ ```bash
38
+ # 1. Install (any Node 18+ server)
39
+ npm install -g @aikdna/kdna-activation-server
40
+
41
+ # 2. Create your first license
42
+ kdna-activation-server --create-license '{
43
+ "domain": "@yourname/your-asset",
44
+ "license_key": "KDNA-LIC-customer-1",
45
+ "issued_to": "customer@example.com",
46
+ "ttl_days": 365
47
+ }'
48
+
49
+ # 3. Start the server
50
+ kdna-activation-server --port 3001 --admin-token "your-secret"
51
+
52
+ # 4. Test
53
+ curl http://localhost:3001/healthz
54
+ curl -X POST http://localhost:3001/v1/entitlements/activate \
55
+ -H 'Content-Type: application/json' \
56
+ -d '{"domain":"@yourname/your-asset","license_key":"KDNA-LIC-customer-1"}'
57
+ ```
58
+
59
+ That's it. No registration, no phone-home, no KDNA Inc. URL.
60
+
61
+ ---
62
+
63
+ ## HTTP API
64
+
65
+ ### `GET /healthz`
66
+
67
+ Health check. Returns 200 with server metadata.
68
+
69
+ ### `GET /v1/server/identity`
70
+
71
+ Returns the server's Ed25519 public key (PEM, hex, and
72
+ fingerprint). Clients use this to verify that an entitlement
73
+ record was really signed by this server.
74
+
75
+ ### `POST /v1/entitlements/activate`
76
+
77
+ Activates a license. Returns a signed entitlement record
78
+ (cryptographically verifiable against `/v1/server/identity`).
79
+
80
+ Request body:
81
+
82
+ ```json
83
+ {
84
+ "domain": "@yourname/your-asset",
85
+ "license_key": "KDNA-LIC-customer-1",
86
+ "machine_fingerprint": "<sha256>"
87
+ }
88
+ ```
89
+
90
+ Optional: `client`, `client_version`, `agent`, `account_id`,
91
+ `device_label`.
92
+
93
+ Response (200): the signed entitlement record (see
94
+ `specs/kdna-entitlement-api.md` §5).
95
+
96
+ Errors:
97
+ - `INVALID_LICENSE_KEY` (404) — key does not match the domain
98
+ - `LICENSE_REVOKED` (403) — license has been revoked
99
+ - `LICENSE_EXPIRED` (403) — `expires_at` is in the past
100
+
101
+ ### `POST /v1/entitlements/sync`
102
+
103
+ Refreshes the entitlement state (updates `last_checked_at` and
104
+ `offline_valid_until`). Returns the signed record. Same
105
+ errors as `/activate`.
106
+
107
+ ### `POST /v1/entitlements/revoke` (admin)
108
+
109
+ Revokes a license. Requires an `Authorization: Bearer
110
+ <admin-token>` header. The admin token is set at server
111
+ startup.
112
+
113
+ Request body:
114
+
115
+ ```json
116
+ {
117
+ "license_id": "lic_abc123",
118
+ "domain": "@yourname/your-asset",
119
+ "reason": "payment_failed",
120
+ "revoked_by": "billing-system"
121
+ }
122
+ ```
123
+
124
+ ### `GET /v1/entitlements/status?domain=...&license_key=...`
125
+
126
+ Introspection. Returns the record (unsigned, for
127
+ introspection only; consumers should use `/activate` or
128
+ `/sync` for the signed record).
129
+
130
+ ---
131
+
132
+ ## CLI
133
+
134
+ ```bash
135
+ # Create a license (one-shot)
136
+ kdna-activation-server --create-license '{"domain":"@x/y","license_key":"KDNA-LIC-1"}'
137
+
138
+ # List all licenses
139
+ kdna-activation-server --list
140
+
141
+ # Revoke
142
+ kdna-activation-server --revoke lic_abc123 --reason "payment_failed"
143
+
144
+ # Start the server
145
+ kdna-activation-server --port 3001 --admin-token "your-secret"
146
+ ```
147
+
148
+ The server keypair is auto-generated on first start and
149
+ stored at `~/.kdna/activation-server/`. The private key is
150
+ mode 0600.
151
+
152
+ ---
153
+
154
+ ## Security properties
155
+
156
+ - **No KDNA Inc. URL is hardcoded.** The server has zero
157
+ outbound network calls during normal operation.
158
+ - **The server keypair is local.** The private key never
159
+ leaves the deployer's machine.
160
+ - **The admin token is deployer-controlled.** Set it at
161
+ startup or omit it to disable `/revoke` over HTTP.
162
+ - **The license_key is the secret.** The server echoes it
163
+ back in the activation record (per spec). Clients MUST
164
+ NOT log or persist it beyond the local activation file.
165
+ - **Records are signed.** Every `/activate` and `/sync`
166
+ response is signed with the server's Ed25519 key. Clients
167
+ can verify against `/v1/server/identity`.
168
+
169
+ ---
170
+
171
+ ## Local development
172
+
173
+ ```bash
174
+ git clone https://github.com/aikdna/kdna-activation-server
175
+ cd kdna-activation-server
176
+ npm test
177
+ ```
178
+
179
+ The tests spin up the server on an OS-assigned port. No
180
+ external services are required.
181
+
182
+ ---
183
+
184
+ ## License
185
+
186
+ Apache 2.0. See [LICENSE](./LICENSE).
187
+
188
+ This server is a license-management reference implementation.
189
+ Trust is the consumer's decision, not the server's claim.
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * kdna-activation-server — CLI entry (Story 24)
4
+ *
5
+ * Self-hostable HTTP activation server. See README.md for
6
+ * self-hosting instructions.
7
+ *
8
+ * Usage:
9
+ * kdna-activation-server [--port 3001] [--data-dir <path>]
10
+ * [--admin-token <secret>]
11
+ * [--create-license <json>]
12
+ * [--list]
13
+ * [--revoke <license-id> --reason "..."]
14
+ *
15
+ * The server is the deployer's own. The protocol does not
16
+ * hardcode any KDNA Inc. URL. The admin token is deployer-
17
+ * controlled. License records are deployer-controlled.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const path = require('node:path');
23
+ const fs = require('node:fs');
24
+ const { startServer, stopServer } = require('../src/server');
25
+ const { makeStore, DEFAULT_DATA_DIR } = require('../src/store');
26
+ const pkg = require('../package.json');
27
+
28
+ function parseArgs(argv) {
29
+ const out = {};
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a.startsWith('--')) {
33
+ const key = a.slice(2);
34
+ const eq = key.indexOf('=');
35
+ if (eq >= 0) {
36
+ out[key.slice(0, eq)] = key.slice(eq + 1);
37
+ } else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
38
+ out[key] = argv[i + 1];
39
+ i++;
40
+ } else {
41
+ out[key] = true;
42
+ }
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function help() {
49
+ return `kdna-activation-server v${pkg.version} — self-hostable activation server
50
+
51
+ Usage:
52
+ kdna-activation-server [serve options]
53
+ kdna-activation-server --create-license '<json>' [--data-dir <path>]
54
+ kdna-activation-server --list [--data-dir <path>]
55
+ kdna-activation-server --revoke <license-id> --reason "..." [--data-dir <path>]
56
+
57
+ Server options:
58
+ --port <n> Port to listen on. Default 3001.
59
+ --host <addr> Host to bind. Default 127.0.0.1.
60
+ --data-dir <path> Where to store entitlement records +
61
+ server keypair. Default
62
+ ~/.kdna/activation-server.
63
+ --admin-token <secret> Bearer token for the /v1/entitlements/revoke
64
+ endpoint. If unset, /revoke returns 401.
65
+ Set this if you want to be able to revoke
66
+ licenses via HTTP.
67
+
68
+ One-shot commands (do not start the server):
69
+ --create-license '<json>'
70
+ Create a new license record. The JSON
71
+ must contain: domain, license_key.
72
+ Optional: issued_to, ttl_days,
73
+ require_machine_binding (default true),
74
+ require_online_check (default true),
75
+ offline_grace_days (default 7),
76
+ allowed_agents (array).
77
+ --list List all license records.
78
+ --revoke <id> Revoke a license by id. Requires --reason.
79
+ --reason "..." Reason for revocation (audit log).
80
+
81
+ Self-hosting:
82
+ Run this on any Node 18+ server. There is no registration
83
+ with KDNA Inc. The license records, the admin token, and
84
+ the server keypair are all deployer-controlled.
85
+ `;
86
+ }
87
+
88
+ async function main() {
89
+ const args = parseArgs(process.argv.slice(2));
90
+ if (args.help || args.h) {
91
+ process.stdout.write(help());
92
+ return;
93
+ }
94
+
95
+ const dataDir = args['data-dir'] || DEFAULT_DATA_DIR;
96
+ fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
97
+ const store = makeStore(dataDir);
98
+
99
+ // One-shot commands
100
+ if (args['create-license']) {
101
+ let body;
102
+ try {
103
+ body = JSON.parse(args['create-license']);
104
+ } catch (e) {
105
+ process.stderr.write(`Error: --create-license value is not valid JSON: ${e.message}\n`);
106
+ process.exit(1);
107
+ }
108
+ if (!body.domain || !body.license_key) {
109
+ process.stderr.write(`Error: --create-license JSON must include domain and license_key\n`);
110
+ process.exit(1);
111
+ }
112
+ const rec = store.create(body);
113
+ process.stdout.write(`Created license:\n ${JSON.stringify(rec, null, 2)}\n`);
114
+ return;
115
+ }
116
+ if (args.list) {
117
+ const recs = store.list();
118
+ process.stdout.write(`${recs.length} license record(s):\n`);
119
+ for (const r of recs) {
120
+ process.stdout.write(` ${r.license_id} ${r.domain} status=${r.status} revoked=${r.revoked}\n`);
121
+ }
122
+ return;
123
+ }
124
+ if (args.revoke) {
125
+ if (!args.reason) {
126
+ process.stderr.write(`Error: --revoke requires --reason\n`);
127
+ process.exit(1);
128
+ }
129
+ const updated = store.revoke(args.revoke, { reason: args.reason, revoked_by: 'cli' });
130
+ if (!updated) {
131
+ process.stderr.write(`Error: no license found with id ${args.revoke}\n`);
132
+ process.exit(1);
133
+ }
134
+ process.stdout.write(`Revoked:\n ${JSON.stringify(updated, null, 2)}\n`);
135
+ return;
136
+ }
137
+
138
+ // Start the server
139
+ const port = args.port ? parseInt(args.port, 10) : 3001;
140
+ const host = args.host || '127.0.0.1';
141
+ if (isNaN(port)) {
142
+ process.stderr.write(`Error: invalid port: ${args.port}\n`);
143
+ process.exit(1);
144
+ }
145
+
146
+ const { server, port: actualPort, keys, dataDir: dd } = await startServer({
147
+ dataDir,
148
+ port,
149
+ host,
150
+ adminToken: args['admin-token'] || null,
151
+ });
152
+
153
+ process.stdout.write(
154
+ `kdna-activation-server v${pkg.version} listening on http://${host}:${actualPort}\n` +
155
+ ` data_dir: ${dd}\n` +
156
+ ` admin_token: ${args['admin-token'] ? '(configured)' : '(NOT set — /revoke returns 401)'}\n` +
157
+ ` public_key: ${keys.publicPem.length} bytes (PEM, ed25519)\n` +
158
+ `\n` +
159
+ `Try:\n` +
160
+ ` curl http://${host}:${actualPort}/healthz\n` +
161
+ ` curl http://${host}:${actualPort}/v1/server/identity\n` +
162
+ ` curl -X POST http://${host}:${actualPort}/v1/entitlements/activate \\\n` +
163
+ ` -H 'Content-Type: application/json' \\\n` +
164
+ ` -d '{"domain":"@yourname/your-asset","license_key":"KDNA-LIC-..."}'\n` +
165
+ `\n` +
166
+ `Create a license:\n` +
167
+ ` kdna-activation-server --create-license '{"domain":"@yourname/your-asset","license_key":"KDNA-LIC-test1"}'\n`,
168
+ );
169
+
170
+ const shutdown = (signal) => {
171
+ process.stdout.write(`\nReceived ${signal}, shutting down...\n`);
172
+ stopServer(server).then(() => process.exit(0));
173
+ };
174
+ process.on('SIGINT', () => shutdown('SIGINT'));
175
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
176
+ }
177
+
178
+ main().catch((e) => {
179
+ process.stderr.write(`Error: ${e.message}\n`);
180
+ process.exit(1);
181
+ });
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@aikdna/kdna-activation-server",
3
+ "version": "0.1.0",
4
+ "description": "KDNA activation server — self-hostable HTTP server for license activation, sync, revocation. Implements specs/kdna-entitlement-api.md and the self-hosting invariant from docs/REMOTE_MODE.md (Story 24).",
5
+ "type": "commonjs",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "kdna-activation-server": "bin/kdna-activation-server.js"
9
+ },
10
+ "files": [
11
+ "src/",
12
+ "bin/",
13
+ "LICENSE",
14
+ "NOTICE",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "start": "node bin/kdna-activation-server.js",
19
+ "test": "node --test tests/"
20
+ },
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "dependencies": {},
25
+ "license": "Apache-2.0",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/aikdna/kdna-activation-server.git"
29
+ },
30
+ "homepage": "https://github.com/aikdna/kdna-activation-server",
31
+ "keywords": [
32
+ "kdna",
33
+ "kdna-activation-server",
34
+ "license",
35
+ "entitlement",
36
+ "self-hosted",
37
+ "creator-license-management"
38
+ ]
39
+ }
package/src/index.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * index.js — public API for @aikdna/kdna-activation-server (Story 24)
3
+ */
4
+
5
+ 'use strict';
6
+
7
+ const server = require('./server');
8
+ const store = require('./store');
9
+ const signing = require('./signing');
10
+
11
+ module.exports = {
12
+ startServer: server.startServer,
13
+ stopServer: server.stopServer,
14
+ makeRequestHandler: server.makeRequestHandler,
15
+ makeStore: store.makeStore,
16
+ ensureKeyPair: signing.ensureKeyPair,
17
+ publicKeyFingerprint: signing.publicKeyFingerprint,
18
+ signEntitlement: signing.signEntitlement,
19
+ verifyEntitlementSignature: signing.verifyEntitlementSignature,
20
+ };
package/src/server.js ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * server.js — activation server HTTP layer (Story 24)
3
+ *
4
+ * Implements the four endpoints from specs/kdna-entitlement-api.md:
5
+ *
6
+ * POST /v1/entitlements/activate
7
+ * Body: { domain, license_key, machine_fingerprint?, ... }
8
+ * Response: activation record (status: "active")
9
+ * Errors: INVALID_LICENSE_KEY, LICENSE_REVOKED, ...
10
+ *
11
+ * POST /v1/entitlements/sync
12
+ * Body: { domain, license_key, license_id? }
13
+ * Response: same as activation (refreshes last_checked_at)
14
+ *
15
+ * POST /v1/entitlements/revoke
16
+ * Auth: Bearer <admin-token>
17
+ * Body: { license_id, domain, reason, revoked_by? }
18
+ * Response: { ok: true, license_id, status: "revoked", ... }
19
+ *
20
+ * GET /v1/entitlements/status?domain=...&license_key=...
21
+ * Response: activation record
22
+ *
23
+ * Plus:
24
+ * GET /v1/server/identity
25
+ * Response: { public_key_pem, public_key_fingerprint,
26
+ * public_key_hex, ... }
27
+ * Lets clients verify signed entitlement records.
28
+ *
29
+ * GET /healthz
30
+ * Response: { ok: true, server, version }
31
+ *
32
+ * Layer isolation: the server returns the activation record's
33
+ * fields verbatim. It does NOT add content-trust claims like
34
+ * "official" or "trusted" — those would contradict the KDNA
35
+ * layer-isolation rules (SPEC §13.1).
36
+ */
37
+
38
+ 'use strict';
39
+
40
+ const http = require('node:http');
41
+ const { URL } = require('node:url');
42
+ const crypto = require('node:crypto');
43
+ const { makeStore } = require('./store');
44
+ const {
45
+ ensureKeyPair,
46
+ publicKeyFingerprint,
47
+ publicKeyRawHex,
48
+ signEntitlement,
49
+ verifyEntitlementSignature,
50
+ } = require('./signing');
51
+
52
+ const DEFAULT_PORT = 3001;
53
+
54
+ function makeRequestHandler(opts) {
55
+ if (!opts.store) throw new Error('store is required');
56
+ if (!opts.keys) throw new Error('keys is required');
57
+ const { store, keys, adminToken } = opts;
58
+
59
+ async function handle(req, res) {
60
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
61
+
62
+ // Health check (always 200, no auth)
63
+ if (req.method === 'GET' && url.pathname === '/healthz') {
64
+ json(res, 200, {
65
+ ok: true,
66
+ server: '@aikdna/kdna-activation-server',
67
+ version: require('../package.json').version,
68
+ data_dir: store.dataDir,
69
+ });
70
+ return;
71
+ }
72
+
73
+ // Server identity — the public key clients use to verify
74
+ // signed entitlement records. No auth (this is a public key).
75
+ if (req.method === 'GET' && url.pathname === '/v1/server/identity') {
76
+ const rawHex = publicKeyRawHex(keys.publicPem);
77
+ json(res, 200, {
78
+ server: '@aikdna/kdna-activation-server',
79
+ version: require('../package.json').version,
80
+ public_key_pem: keys.publicPem.trim(),
81
+ public_key_fingerprint: publicKeyFingerprint(keys.publicPem),
82
+ public_key_hex: rawHex,
83
+ algorithm: 'ed25519',
84
+ signature_version: '1.0',
85
+ });
86
+ return;
87
+ }
88
+
89
+ // Activation
90
+ if (req.method === 'POST' && url.pathname === '/v1/entitlements/activate') {
91
+ await readJson(req, res, async (body) => {
92
+ const { domain, license_key } = body || {};
93
+ if (!domain) return jsonError(res, 400, 'MISSING_DOMAIN', 'domain is required');
94
+ if (!license_key) return jsonError(res, 400, 'MISSING_LICENSE_KEY', 'license_key is required');
95
+
96
+ const rec = store.getByKey(license_key);
97
+ if (!rec || rec.domain !== domain) {
98
+ return jsonError(res, 404, 'INVALID_LICENSE_KEY',
99
+ 'license_key does not match the requested domain');
100
+ }
101
+ if (rec.revoked || rec.status === 'revoked') {
102
+ return jsonError(res, 403, 'LICENSE_REVOKED',
103
+ 'license has been revoked',
104
+ { license_id: rec.license_id, revoked_at: rec.revoked_at, revocation_reason: rec.revocation_reason });
105
+ }
106
+ if (rec.expires_at && new Date(rec.expires_at) < new Date()) {
107
+ return jsonError(res, 403, 'LICENSE_EXPIRED',
108
+ 'license has expired',
109
+ { license_id: rec.license_id, expires_at: rec.expires_at });
110
+ }
111
+ // Activate: refresh last_checked_at + offline_valid_until
112
+ store.updateSync(rec.license_id);
113
+ const fresh = store.get(rec.license_id);
114
+ const signed = signEntitlement(stripForApi(fresh), keys.privatePem);
115
+ return json(res, 200, signed);
116
+ });
117
+ return;
118
+ }
119
+
120
+ // Sync
121
+ if (req.method === 'POST' && url.pathname === '/v1/entitlements/sync') {
122
+ await readJson(req, res, async (body) => {
123
+ const { domain, license_key, license_id } = body || {};
124
+ let rec = null;
125
+ if (license_id) rec = store.get(license_id);
126
+ if (!rec && license_key) rec = store.getByKey(license_key);
127
+ if (!rec || (domain && rec.domain !== domain)) {
128
+ return jsonError(res, 404, 'INVALID_LICENSE_KEY',
129
+ 'no entitlement matches the provided identifiers');
130
+ }
131
+ if (rec.revoked || rec.status === 'revoked') {
132
+ return jsonError(res, 403, 'LICENSE_REVOKED',
133
+ 'license has been revoked',
134
+ { license_id: rec.license_id, revoked_at: rec.revoked_at, revocation_reason: rec.revocation_reason });
135
+ }
136
+ store.updateSync(rec.license_id);
137
+ const fresh = store.get(rec.license_id);
138
+ const signed = signEntitlement(stripForApi(fresh), keys.privatePem);
139
+ return json(res, 200, signed);
140
+ });
141
+ return;
142
+ }
143
+
144
+ // Status (introspection; lightweight)
145
+ if (req.method === 'GET' && url.pathname === '/v1/entitlements/status') {
146
+ const domain = url.searchParams.get('domain');
147
+ const licenseKey = url.searchParams.get('license_key');
148
+ const licenseId = url.searchParams.get('license_id');
149
+ let rec = null;
150
+ if (licenseId) rec = store.get(licenseId);
151
+ if (!rec && licenseKey) rec = store.getByKey(licenseKey);
152
+ if (!rec || (domain && rec.domain !== domain)) {
153
+ return jsonError(res, 404, 'NOT_FOUND', 'no entitlement matches');
154
+ }
155
+ return json(res, 200, stripForApi(rec));
156
+ }
157
+
158
+ // Revoke (admin-only, bearer token)
159
+ if (req.method === 'POST' && url.pathname === '/v1/entitlements/revoke') {
160
+ const auth = req.headers.authorization || '';
161
+ if (!adminToken || !auth.startsWith('Bearer ') || auth.slice(7) !== adminToken) {
162
+ return jsonError(res, 401, 'UNAUTHORIZED',
163
+ 'admin bearer token required');
164
+ }
165
+ await readJson(req, res, async (body) => {
166
+ const { license_id, domain, reason, revoked_by } = body || {};
167
+ if (!license_id) return jsonError(res, 400, 'MISSING_LICENSE_ID', 'license_id is required');
168
+ const rec = store.get(license_id);
169
+ if (!rec || (domain && rec.domain !== domain)) {
170
+ return jsonError(res, 404, 'NOT_FOUND', 'no entitlement matches');
171
+ }
172
+ const updated = store.revoke(license_id, { reason, revoked_by });
173
+ return json(res, 200, {
174
+ ok: true,
175
+ license_id,
176
+ status: 'revoked',
177
+ revoked: true,
178
+ revoked_at: updated.revoked_at,
179
+ reason: updated.revocation_reason,
180
+ revoked_by: updated.revoked_by,
181
+ });
182
+ });
183
+ return;
184
+ }
185
+
186
+ jsonError(res, 404, 'NOT_FOUND', `no handler for ${req.method} ${url.pathname}`);
187
+ }
188
+
189
+ return handle;
190
+ }
191
+
192
+ /**
193
+ * Strip internal fields from the API response. The store keeps
194
+ * `updated_at`, `revoked_by`, etc. for bookkeeping; these are
195
+ * not part of the public entitlement record shape.
196
+ */
197
+ function stripForApi(rec) {
198
+ const out = { ...rec };
199
+ delete out.revoked_by;
200
+ return out;
201
+ }
202
+
203
+ function readJson(req, res, fn) {
204
+ let body = '';
205
+ req.on('data', (chunk) => {
206
+ body += chunk;
207
+ if (body.length > 64 * 1024) {
208
+ req.destroy();
209
+ jsonError(res, 413, 'REQUEST_TOO_LARGE', 'request body exceeds 64KB');
210
+ }
211
+ });
212
+ req.on('end', async () => {
213
+ let parsed;
214
+ try {
215
+ parsed = body.length === 0 ? {} : JSON.parse(body);
216
+ } catch (e) {
217
+ return jsonError(res, 400, 'INVALID_JSON', `not valid JSON: ${e.message}`);
218
+ }
219
+ try {
220
+ await fn(parsed);
221
+ } catch (e) {
222
+ jsonError(res, 500, 'INTERNAL_ERROR', e.message);
223
+ }
224
+ });
225
+ }
226
+
227
+ function json(res, status, body) {
228
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
229
+ res.end(JSON.stringify(body, null, 2) + '\n');
230
+ }
231
+
232
+ function jsonError(res, status, code, message, extra) {
233
+ json(res, status, { ok: false, error: { code, message, retryable: false, ...(extra || {}) } });
234
+ }
235
+
236
+ function startServer(opts = {}) {
237
+ return new Promise((resolve, reject) => {
238
+ const dataDir = opts.dataDir || require('./store').DEFAULT_DATA_DIR;
239
+ const store = opts.store || makeStore(dataDir);
240
+ const keys = opts.keys || ensureKeyPair(dataDir);
241
+ const handler = makeRequestHandler({
242
+ store,
243
+ keys,
244
+ adminToken: opts.adminToken || null,
245
+ });
246
+ const server = http.createServer(handler);
247
+ server.on('error', reject);
248
+ server.listen(typeof opts.port === 'number' ? opts.port : DEFAULT_PORT, opts.host || '127.0.0.1', () => {
249
+ const addr = server.address();
250
+ resolve({ server, port: addr.port, host: addr.address, store, keys, dataDir });
251
+ });
252
+ });
253
+ }
254
+
255
+ function stopServer(server) {
256
+ return new Promise((resolve) => {
257
+ if (!server) return resolve();
258
+ server.close(() => resolve());
259
+ });
260
+ }
261
+
262
+ module.exports = {
263
+ makeRequestHandler,
264
+ startServer,
265
+ stopServer,
266
+ };
package/src/signing.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * signing.js — Ed25519 signing of entitlement records (Story 24)
3
+ *
4
+ * The activation server can sign the entitlement records it
5
+ * returns. This lets clients verify that a record really came
6
+ * from the activation server they think it came from (the
7
+ * signed record + the server's public key is enough; no
8
+ * central CA needed).
9
+ *
10
+ * The keypair is generated on first server start and stored
11
+ * alongside the data directory. The private key is stored with
12
+ * mode 0600.
13
+ *
14
+ * This is the creator's identity key for the activation server.
15
+ * The same identity model as kdna-cli's `kdna identity init`
16
+ * (Story 19) — but in a separate file because the server
17
+ * identity is the SERVER's, not the deployer's CLI identity.
18
+ *
19
+ * Story 19 design contract:
20
+ * - Each actor generates their own key pair.
21
+ * - KDNA Inc. holds NO private keys.
22
+ * - No TUF, no registry, no central authority.
23
+ *
24
+ * Same rules apply here: the server generates its own key.
25
+ * Clients verify against the server's public key (which the
26
+ * server advertises at GET /v1/server/identity).
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
33
+ const crypto = require('node:crypto');
34
+
35
+ const PRIVATE_KEY_PATH_DEFAULT = 'activation-server-ed25519.key';
36
+ const PUBLIC_KEY_PATH_DEFAULT = 'activation-server-ed25519.pub';
37
+
38
+ function keyPaths(dataDir) {
39
+ return {
40
+ priv: path.join(dataDir, PRIVATE_KEY_PATH_DEFAULT),
41
+ pub: path.join(dataDir, PUBLIC_KEY_PATH_DEFAULT),
42
+ };
43
+ }
44
+
45
+ function ensureKeyPair(dataDir) {
46
+ fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
47
+ const { priv, pub } = keyPaths(dataDir);
48
+ if (fs.existsSync(priv) && fs.existsSync(pub)) {
49
+ return {
50
+ privatePem: fs.readFileSync(priv, 'utf8'),
51
+ publicPem: fs.readFileSync(pub, 'utf8'),
52
+ };
53
+ }
54
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', {
55
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
56
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
57
+ });
58
+ fs.writeFileSync(priv, privateKey, { mode: 0o600 });
59
+ fs.writeFileSync(pub, publicKey, { mode: 0o644 });
60
+ return { privatePem: privateKey, publicPem: publicKey };
61
+ }
62
+
63
+ function publicKeyFingerprint(publicPem) {
64
+ return crypto.createHash('sha256').update(publicPem).digest('hex').substring(0, 16);
65
+ }
66
+
67
+ function publicKeyRawHex(publicPem) {
68
+ const obj = crypto.createPublicKey({ key: publicPem, format: 'pem', type: 'spki' });
69
+ const der = obj.export({ type: 'spki', format: 'der' });
70
+ return Buffer.from(der.subarray(der.length - 32)).toString('hex');
71
+ }
72
+
73
+ /**
74
+ * Sign an entitlement record. The signature covers the
75
+ * canonical JSON of the body (everything except
76
+ * signature_base64). The signature is base64.
77
+ */
78
+ function signEntitlement(record, privatePem) {
79
+ const { signature_base64: _ignore, ...body } = record;
80
+ const stable = stableStringify(body);
81
+ const sig = crypto.sign(null, Buffer.from(stable, 'utf8'),
82
+ crypto.createPrivateKey({ key: privatePem, format: 'pem', type: 'pkcs8' }));
83
+ return { ...record, signature_base64: sig.toString('base64') };
84
+ }
85
+
86
+ function verifyEntitlementSignature(record, publicPem) {
87
+ const { signature_base64, ...body } = record;
88
+ if (!signature_base64) return { ok: false, reason: 'no signature' };
89
+ const stable = stableStringify(body);
90
+ const ok = crypto.verify(
91
+ null,
92
+ Buffer.from(stable, 'utf8'),
93
+ crypto.createPublicKey({ key: publicPem, format: 'pem', type: 'spki' }),
94
+ Buffer.from(signature_base64, 'base64'),
95
+ );
96
+ return { ok, reason: ok ? null : 'signature does not verify' };
97
+ }
98
+
99
+ function stableStringify(value) {
100
+ if (Array.isArray(value)) {
101
+ return `[${value.map(stableStringify).join(',')}]`;
102
+ }
103
+ if (value && typeof value === 'object') {
104
+ return `{${Object.keys(value)
105
+ .sort()
106
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
107
+ .join(',')}}`;
108
+ }
109
+ return JSON.stringify(value);
110
+ }
111
+
112
+ module.exports = {
113
+ ensureKeyPair,
114
+ keyPaths,
115
+ publicKeyFingerprint,
116
+ publicKeyRawHex,
117
+ signEntitlement,
118
+ verifyEntitlementSignature,
119
+ stableStringify,
120
+ };
package/src/store.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * store.js — entitlement record storage (Story 24)
3
+ *
4
+ * Per docs/REMOTE_MODE.md and specs/kdna-entitlement-api.md,
5
+ * the activation server stores entitlement records. The design
6
+ * contract calls for SQLite ("zero external dependencies for the
7
+ * simplest deployment path"). The v0.1.0 implementation uses a
8
+ * single JSON file (one file per license_id) for the simplest
9
+ * deployment path. A future version can swap in SQLite without
10
+ * changing the public API.
11
+ *
12
+ * Each record has the shape documented in
13
+ * specs/kdna-entitlement-api.md §10 (Local Activation File).
14
+ * The activation server returns the same shape (plus optional
15
+ * Ed25519 signature) on /activate and /sync endpoints.
16
+ *
17
+ * The store is the SOURCE OF TRUTH for entitlement state. The
18
+ * CLI's local copy at ~/.kdna/licenses/<domain>.json is a
19
+ * CLIENT cache, not the source of truth. If a client claims
20
+ * "active" but the server says "revoked", the server wins.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const fs = require('node:fs');
26
+ const path = require('node:path');
27
+ const crypto = require('node:crypto');
28
+
29
+ const DEFAULT_DATA_DIR = path.join(
30
+ process.env.HOME || process.env.USERPROFILE || '.',
31
+ '.kdna',
32
+ 'activation-server',
33
+ );
34
+
35
+ function makeStore(dataDir) {
36
+ if (!dataDir) dataDir = DEFAULT_DATA_DIR;
37
+ fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
38
+
39
+ function recordPath(licenseId) {
40
+ if (!licenseId || !/^[A-Za-z0-9_\-:.]{1,128}$/.test(licenseId)) {
41
+ throw new Error(`invalid license_id: ${licenseId}`);
42
+ }
43
+ return path.join(dataDir, `${licenseId.replace(/[^A-Za-z0-9_\-]/g, '_')}.json`);
44
+ }
45
+
46
+ function get(licenseId) {
47
+ const p = recordPath(licenseId);
48
+ if (!fs.existsSync(p)) return null;
49
+ try {
50
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
51
+ } catch (e) {
52
+ throw new Error(`failed to read ${p}: ${e.message}`);
53
+ }
54
+ }
55
+
56
+ function getByKey(licenseKey) {
57
+ // The store is keyed by license_id. The CLI sends both
58
+ // domain + license_key. We index license_key by scanning
59
+ // all records (acceptable for self-hosted single-creator
60
+ // scale; a future version can use a secondary index).
61
+ const files = fs.readdirSync(dataDir).filter((f) => f.endsWith('.json'));
62
+ for (const f of files) {
63
+ try {
64
+ const rec = JSON.parse(fs.readFileSync(path.join(dataDir, f), 'utf8'));
65
+ if (rec.license_key === licenseKey) return rec;
66
+ } catch (_) {
67
+ // ignore malformed files
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function put(record) {
74
+ if (!record || !record.license_id) {
75
+ throw new Error('record.license_id is required');
76
+ }
77
+ const p = recordPath(record.license_id);
78
+ record.updated_at = new Date().toISOString();
79
+ fs.writeFileSync(p, JSON.stringify(record, null, 2) + '\n', { mode: 0o600 });
80
+ return record;
81
+ }
82
+
83
+ function list() {
84
+ const out = [];
85
+ const files = fs.readdirSync(dataDir).filter((f) => f.endsWith('.json'));
86
+ for (const f of files) {
87
+ try {
88
+ out.push(JSON.parse(fs.readFileSync(path.join(dataDir, f), 'utf8')));
89
+ } catch (_) {
90
+ // ignore
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ function create({ domain, license_key, license_id, issued_to, require_machine_binding, require_online_check, offline_grace_days, allowed_agents, ttl_days, issued_at }) {
97
+ if (!domain) throw new Error('domain is required');
98
+ if (!license_key) throw new Error('license_key is required');
99
+ if (!license_id) license_id = `lic_${crypto.randomBytes(8).toString('hex')}`;
100
+
101
+ const record = {
102
+ version: '1.0',
103
+ license_id,
104
+ license_key,
105
+ domain,
106
+ issued_to: issued_to || null,
107
+ issued_at: issued_at || new Date().toISOString(),
108
+ expires_at: ttl_days
109
+ ? new Date(Date.now() + ttl_days * 24 * 60 * 60 * 1000).toISOString()
110
+ : null,
111
+ status: 'active',
112
+ revoked: false,
113
+ revoked_at: null,
114
+ revocation_reason: null,
115
+ require_machine_binding: require_machine_binding !== false,
116
+ require_online_check: require_online_check !== false,
117
+ offline_grace_days: typeof offline_grace_days === 'number' ? offline_grace_days : 7,
118
+ allowed_agents: Array.isArray(allowed_agents) ? allowed_agents : null,
119
+ };
120
+ return put(record);
121
+ }
122
+
123
+ function revoke(licenseId, { reason, revoked_by } = {}) {
124
+ const rec = get(licenseId);
125
+ if (!rec) return null;
126
+ rec.status = 'revoked';
127
+ rec.revoked = true;
128
+ rec.revoked_at = new Date().toISOString();
129
+ rec.revocation_reason = reason || null;
130
+ rec.revoked_by = revoked_by || null;
131
+ return put(rec);
132
+ }
133
+
134
+ function updateSync(licenseId) {
135
+ const rec = get(licenseId);
136
+ if (!rec) return null;
137
+ rec.last_checked_at = new Date().toISOString();
138
+ rec.offline_valid_until = new Date(
139
+ Date.now() + (rec.offline_grace_days || 7) * 24 * 60 * 60 * 1000,
140
+ ).toISOString();
141
+ return put(rec);
142
+ }
143
+
144
+ return { get, getByKey, put, list, create, revoke, updateSync, dataDir };
145
+ }
146
+
147
+ module.exports = { makeStore, DEFAULT_DATA_DIR };