@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 +17 -0
- package/NOTICE +14 -0
- package/README.md +189 -0
- package/bin/kdna-activation-server.js +181 -0
- package/package.json +39 -0
- package/src/index.js +20 -0
- package/src/server.js +266 -0
- package/src/signing.js +120 -0
- package/src/store.js +147 -0
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 };
|