@feasibleone/blong-gogo 1.34.0 → 1.35.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/CHANGELOG.md +17 -0
- package/bin/blong.ts +13 -0
- package/package.json +5 -3
- package/src/AdapterBase.ts +54 -2
- package/src/BrowserLog.ts +16 -2
- package/src/Gateway.ts +175 -3
- package/src/Log.ts +73 -3
- package/src/Registry.ts +7 -0
- package/src/RpcServer.ts +14 -3
- package/src/SemanticLog.test.ts +509 -0
- package/src/SemanticLog.ts +264 -0
- package/src/Watch.ts +69 -21
- package/src/callTrace.test.ts +80 -0
- package/src/callTrace.ts +126 -0
- package/src/cliHelp.ts +2 -0
- package/src/devKeys.ts +38 -0
- package/src/exit.intent.test.ts +21 -6
- package/src/frameworkRealms.test.ts +177 -0
- package/src/grant.test.ts +168 -0
- package/src/grant.ts +273 -0
- package/src/handlerProxy.ts +72 -3
- package/src/layerProxy.ts +9 -1
- package/src/lib.ts +69 -0
- package/src/load.ts +410 -34
- package/src/loadServer.ts +7 -0
- package/src/orchestrator/common/dispatch.ts +30 -6
- package/src/pino-pretty.ts +1 -1
- package/src/semanticContext.test.ts +322 -0
- package/src/semanticContext.ts +351 -0
- package/src/semanticRecord.ts +54 -0
- package/template/index.browser.ts +4 -0
package/src/exit.intent.test.ts
CHANGED
|
@@ -9,9 +9,16 @@ import load from './loadServer.ts';
|
|
|
9
9
|
* intent names (which is what `runServer` used to do with
|
|
10
10
|
* `process.env.CI && !intents.includes('playwright')`).
|
|
11
11
|
*
|
|
12
|
-
* Only `load` is exercised, never `start`, so
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Only `load` is exercised, never `start`, so no watcher is created and the
|
|
13
|
+
* assertions here are about the flag — the "no listener" property is checked by
|
|
14
|
+
* actually running `blong cli`.
|
|
15
|
+
*
|
|
16
|
+
* Every load is stopped though, because `load` is not as idle as it looks: the
|
|
17
|
+
* `dev` and `integration` intents open the semantic-log cluster service, which
|
|
18
|
+
* listens on a socket, and a registry that is never stopped leaves it open. The
|
|
19
|
+
* suite then passes every assertion and reports `timeout!` — what a live server
|
|
20
|
+
* does to a tap process that has nothing left to run (`Registry.stop` closes it
|
|
21
|
+
* for the same reason).
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
24
|
/**
|
|
@@ -41,7 +48,11 @@ async function exitFor(intents: string[]): Promise<boolean | undefined> {
|
|
|
41
48
|
{},
|
|
42
49
|
intents,
|
|
43
50
|
);
|
|
44
|
-
|
|
51
|
+
try {
|
|
52
|
+
return registry.exit;
|
|
53
|
+
} finally {
|
|
54
|
+
await registry.stop();
|
|
55
|
+
}
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
test('cli and db are short-lived', async t => {
|
|
@@ -93,6 +104,10 @@ test('the cli intent turns off everything that would outlive a command', async t
|
|
|
93
104
|
{},
|
|
94
105
|
['cli'],
|
|
95
106
|
);
|
|
96
|
-
|
|
97
|
-
|
|
107
|
+
try {
|
|
108
|
+
t.equal(registry.exit, true, 'short-lived');
|
|
109
|
+
t.ok(Array.isArray(registry.describe?.().realms), 'the realm still loaded');
|
|
110
|
+
} finally {
|
|
111
|
+
await registry.stop();
|
|
112
|
+
}
|
|
98
113
|
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {mkdtempSync, mkdirSync, rmSync, writeFileSync} from 'node:fs';
|
|
2
|
+
import Module, {createRequire} from 'node:module';
|
|
3
|
+
import {tmpdir} from 'node:os';
|
|
4
|
+
import {dirname, join} from 'node:path';
|
|
5
|
+
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
6
|
+
import {server} from '@feasibleone/blong';
|
|
7
|
+
import {test} from 'tap';
|
|
8
|
+
|
|
9
|
+
import load from './loadServer.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Which realms the framework loads beside a suite is decided by the suite's
|
|
13
|
+
* *manifest*, not by what happens to resolve.
|
|
14
|
+
*
|
|
15
|
+
* The two questions look like one, and only differ when the ambient resolution
|
|
16
|
+
* lies: `createRequire(...).resolve()` falls back to `NODE_PATH`, and the tap
|
|
17
|
+
* runner points that at the pnpm store — where every workspace package can be
|
|
18
|
+
* found, including a published copy of blong-server. Under `blong-dev test` the
|
|
19
|
+
* framework therefore loaded realms into suites that declare none of them, and
|
|
20
|
+
* `demo/blong-cli` (a command that runs on the `cli` intent and reads no
|
|
21
|
+
* database) died with `Unknown database 'blong-cli'` (F-206).
|
|
22
|
+
*
|
|
23
|
+
* The fixture below reproduces exactly that shape without touching the runner:
|
|
24
|
+
* a package the ambient resolution can find, a suite beside a manifest that does
|
|
25
|
+
* or does not declare it, and one assertion per case. The realm that loads is a
|
|
26
|
+
* marker realm of its own rather than the real blong-server, which would want a
|
|
27
|
+
* database and a database name of its own.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The marker realm the fake blong-server package exports.
|
|
32
|
+
*
|
|
33
|
+
* A realm is a factory carrying the framework's `kind` symbol, and the symbol is
|
|
34
|
+
* registered with `Symbol.for`, so a fixture can be a realm without reaching for
|
|
35
|
+
* `@feasibleone/blong` — which is what lets it live in a tmp directory. It is CJS
|
|
36
|
+
* because the runner transpiles the suite to CJS, so the loader's `import()` of
|
|
37
|
+
* it is a `require()`, and requiring an ES module from there is a cycle.
|
|
38
|
+
*/
|
|
39
|
+
const MARKER_REALM = `const Kind = Symbol.for('blong:kind');
|
|
40
|
+
|
|
41
|
+
module.exports = Object.defineProperty(
|
|
42
|
+
() => ({
|
|
43
|
+
url: require('node:url').pathToFileURL(__filename).href,
|
|
44
|
+
pkg: {name: 'blong-server', version: '0.0.0'},
|
|
45
|
+
config: {default: {}},
|
|
46
|
+
}),
|
|
47
|
+
Kind,
|
|
48
|
+
{value: 'solution'},
|
|
49
|
+
);
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A directory that is both the suite's home and a `NODE_PATH` entry.
|
|
54
|
+
*
|
|
55
|
+
* `node_modules/@feasibleone/blong-server` is the package the ambient resolution
|
|
56
|
+
* answers with, and its `exports` maps the pinned `/server.ts` subpath onto the
|
|
57
|
+
* marker realm above. `suite.ts` is named by the suite's `url` but never
|
|
58
|
+
* imported: it only gives `createRequire(suiteUrl)('./package.json')` a directory
|
|
59
|
+
* to resolve the suite's manifest in.
|
|
60
|
+
*/
|
|
61
|
+
function fixture(declaresMarker: boolean): {url: string; dispose: () => void} {
|
|
62
|
+
const dir = mkdtempSync(join(tmpdir(), 'blong-framework-realms-'));
|
|
63
|
+
const marker = join(dir, 'node_modules', '@feasibleone', 'blong-server');
|
|
64
|
+
mkdirSync(marker, {recursive: true});
|
|
65
|
+
writeFileSync(
|
|
66
|
+
join(marker, 'package.json'),
|
|
67
|
+
JSON.stringify({name: 'blong-server', exports: {'./server.ts': './server.cjs'}}),
|
|
68
|
+
);
|
|
69
|
+
writeFileSync(join(marker, 'server.cjs'), MARKER_REALM);
|
|
70
|
+
writeFileSync(join(dir, 'suite.ts'), '');
|
|
71
|
+
writeFileSync(
|
|
72
|
+
join(dir, 'package.json'),
|
|
73
|
+
JSON.stringify({
|
|
74
|
+
name: 'framework-realms-fixture',
|
|
75
|
+
version: '0.0.0',
|
|
76
|
+
...(declaresMarker ? {dependencies: {'@feasibleone/blong-server': '1.0.0'}} : {}),
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
return {
|
|
80
|
+
url: pathToFileURL(join(dir, 'suite.ts')).href,
|
|
81
|
+
dispose: () => rmSync(dir, {recursive: true, force: true}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Run `body` with `dir` on the module paths Node resolves against, which is the
|
|
87
|
+
* effect `NODE_PATH` has in a child process. `globalPaths` is read on every
|
|
88
|
+
* resolve, so pushing onto it is enough — no re-init and no environment
|
|
89
|
+
* manipulation, and the entry is taken back off afterwards.
|
|
90
|
+
*/
|
|
91
|
+
async function withAmbientResolution<T>(dir: string, body: () => Promise<T>): Promise<T> {
|
|
92
|
+
const {globalPaths} = Module as unknown as {globalPaths: string[]};
|
|
93
|
+
const path = join(dir, 'node_modules');
|
|
94
|
+
globalPaths.unshift(path);
|
|
95
|
+
try {
|
|
96
|
+
return await body();
|
|
97
|
+
} finally {
|
|
98
|
+
globalPaths.splice(globalPaths.indexOf(path), 1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A suite that names no children: everything it loads, it loads because its
|
|
104
|
+
* manifest declares it — or because the ambient resolution found it, which is
|
|
105
|
+
* the bug this asserts against.
|
|
106
|
+
*/
|
|
107
|
+
const suite = (url: string, pkg?: {name: string; version: string}) =>
|
|
108
|
+
server(
|
|
109
|
+
() =>
|
|
110
|
+
({
|
|
111
|
+
url,
|
|
112
|
+
...(pkg ? {pkg} : {}),
|
|
113
|
+
children: [],
|
|
114
|
+
config: {default: {}},
|
|
115
|
+
}) as never,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
test('a realm the suite does not declare is not loaded, though NODE_PATH resolves it', async t => {
|
|
119
|
+
const {url, dispose} = fixture(false);
|
|
120
|
+
try {
|
|
121
|
+
await withAmbientResolution(dirname(fileURLToPath(url)), async () => {
|
|
122
|
+
// The premise: the marker realm *is* resolvable from the suite's own
|
|
123
|
+
// location, so the loader would have loaded it had it asked by
|
|
124
|
+
// resolution.
|
|
125
|
+
t.match(
|
|
126
|
+
createRequire(url).resolve('@feasibleone/blong-server/server.ts'),
|
|
127
|
+
/blong-server/,
|
|
128
|
+
'the ambient resolution finds the realm the suite does not declare',
|
|
129
|
+
);
|
|
130
|
+
const registry = await load(suite(url) as never, 'undeclared', {}, ['cli']);
|
|
131
|
+
t.notOk(
|
|
132
|
+
registry.describe?.().realms.includes('server'),
|
|
133
|
+
'and the loader leaves it out',
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
} finally {
|
|
137
|
+
dispose();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('a realm the suite declares is loaded', async t => {
|
|
142
|
+
const {url, dispose} = fixture(true);
|
|
143
|
+
try {
|
|
144
|
+
await withAmbientResolution(dirname(fileURLToPath(url)), async () => {
|
|
145
|
+
const registry = await load(suite(url) as never, 'declared', {}, ['cli']);
|
|
146
|
+
t.ok(
|
|
147
|
+
registry.describe?.().realms.includes('server'),
|
|
148
|
+
'the declared realm is loaded',
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
} finally {
|
|
152
|
+
dispose();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('a trimmed manifest does not un-declare a realm', async t => {
|
|
157
|
+
// `blong-suite/server.ts` passes `{name, version}` and nothing else, so the
|
|
158
|
+
// dependencies only exist in the file beside it. Reading the trimmed manifest as
|
|
159
|
+
// the whole answer would drop the realm that suite depends on.
|
|
160
|
+
const {url, dispose} = fixture(true);
|
|
161
|
+
try {
|
|
162
|
+
await withAmbientResolution(dirname(fileURLToPath(url)), async () => {
|
|
163
|
+
const registry = await load(
|
|
164
|
+
suite(url, {name: 'framework-realms-fixture', version: '0.0.0'}) as never,
|
|
165
|
+
'trimmed',
|
|
166
|
+
{},
|
|
167
|
+
['cli'],
|
|
168
|
+
);
|
|
169
|
+
t.ok(
|
|
170
|
+
registry.describe?.().realms.includes('server'),
|
|
171
|
+
'the file beside the suite still counts',
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
} finally {
|
|
175
|
+
dispose();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grants: a short-lived token that asks for one capability, on one request.
|
|
3
|
+
*
|
|
4
|
+
* What matters here is that the mechanism is *narrow*: it verifies only what it
|
|
5
|
+
* signed, honours only what it knows, and answers "no grant" — never an error —
|
|
6
|
+
* for everything else, because it arrives on a header anybody can set.
|
|
7
|
+
*/
|
|
8
|
+
import {exportJWK, generateKeyPair, importJWK, SignJWT} from 'jose';
|
|
9
|
+
import t from 'tap';
|
|
10
|
+
import {devSignKey} from './devKeys.ts';
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_TTL_SECONDS,
|
|
13
|
+
granted,
|
|
14
|
+
grantedCalls,
|
|
15
|
+
grantKeyFrom,
|
|
16
|
+
mintGrant,
|
|
17
|
+
parseTtl,
|
|
18
|
+
verifyGrant,
|
|
19
|
+
} from './grant.ts';
|
|
20
|
+
|
|
21
|
+
const secondsAgo = (seconds: number): number => Math.floor(Date.now() / 1000) - seconds;
|
|
22
|
+
|
|
23
|
+
t.test('a minted grant verifies, and carries only the capability it was asked for', async t => {
|
|
24
|
+
const token = await mintGrant({cap: ['calls'], ttlSeconds: 60});
|
|
25
|
+
const claims = await verifyGrant(token);
|
|
26
|
+
t.equal(granted(claims, 'calls'), true, 'the capability is honoured');
|
|
27
|
+
t.equal(claims?.cap.length, 1, 'and nothing else is claimed');
|
|
28
|
+
t.ok((claims?.exp ?? 0) > secondsAgo(0), 'the expiry is in the future');
|
|
29
|
+
t.end();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
t.test('the default lifetime is minutes, not a session', async t => {
|
|
33
|
+
t.equal(DEFAULT_TTL_SECONDS, 900);
|
|
34
|
+
const claims = await verifyGrant(await mintGrant({cap: ['calls']}));
|
|
35
|
+
const lifetime = (claims?.exp ?? 0) - (claims?.iat ?? 0);
|
|
36
|
+
t.equal(
|
|
37
|
+
lifetime,
|
|
38
|
+
DEFAULT_TTL_SECONDS,
|
|
39
|
+
'the token expires a quarter of an hour after it was minted',
|
|
40
|
+
);
|
|
41
|
+
t.end();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
t.test('an expired grant is no grant', async t => {
|
|
45
|
+
// Signed with a key this process does not hold, so the failure is the expiry
|
|
46
|
+
// and not the signature: the token below is well-formed in every other way.
|
|
47
|
+
const {privateKey} = await generateKeyPair('ES384', {extractable: true});
|
|
48
|
+
const jwk = {...(await exportJWK(privateKey)), alg: 'ES384'};
|
|
49
|
+
const expired = await new SignJWT({cap: ['calls']})
|
|
50
|
+
.setProtectedHeader({alg: 'ES384'})
|
|
51
|
+
.setIssuedAt(secondsAgo(120))
|
|
52
|
+
.setExpirationTime(secondsAgo(60))
|
|
53
|
+
.sign(await importJWK(jwk, 'ES384'));
|
|
54
|
+
t.equal(
|
|
55
|
+
await verifyGrant(expired, jwk),
|
|
56
|
+
undefined,
|
|
57
|
+
'a token that has expired verifies as nothing',
|
|
58
|
+
);
|
|
59
|
+
t.end();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
t.test('a token signed by another key is refused', async t => {
|
|
63
|
+
const {privateKey} = await generateKeyPair('ES384', {extractable: true});
|
|
64
|
+
const other = {...(await exportJWK(privateKey)), alg: 'ES384'};
|
|
65
|
+
const foreign = await mintGrant({cap: ['calls'], ttlSeconds: 60, key: other});
|
|
66
|
+
t.equal(await verifyGrant(foreign), undefined, 'the framework key does not verify it');
|
|
67
|
+
t.equal((await verifyGrant(foreign, other))?.cap.join(), 'calls', 'and its own key does');
|
|
68
|
+
t.end();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
t.test('a tampered or absent token is no grant, not an error', async t => {
|
|
72
|
+
const token = await mintGrant({cap: ['calls'], ttlSeconds: 60});
|
|
73
|
+
t.equal(await verifyGrant(`${token.slice(0, -3)}abc`), undefined, 'a mangled signature');
|
|
74
|
+
t.equal(await verifyGrant('not-a-token'), undefined, 'nonsense');
|
|
75
|
+
t.equal(await verifyGrant(''), undefined, 'an empty header');
|
|
76
|
+
t.equal(await verifyGrant(undefined), undefined, 'and no header at all');
|
|
77
|
+
t.end();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
t.test('a capability this build does not know is dropped, not trusted', async t => {
|
|
81
|
+
const claims = await verifyGrant(await mintGrant({cap: [], ttlSeconds: 60}));
|
|
82
|
+
t.same(claims?.cap, [], 'an empty claim grants nothing');
|
|
83
|
+
t.equal(granted(claims, 'calls'), false);
|
|
84
|
+
t.equal(granted(undefined, 'calls'), false, 'and neither does a missing one');
|
|
85
|
+
t.end();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
t.test('minting without a private key says so', async t => {
|
|
89
|
+
const {publicKey} = await generateKeyPair('ES384', {extractable: true});
|
|
90
|
+
const publicOnly = {...(await exportJWK(publicKey)), alg: 'ES384'};
|
|
91
|
+
await t.rejects(
|
|
92
|
+
mintGrant({cap: ['calls'], key: publicOnly}),
|
|
93
|
+
/no private half/,
|
|
94
|
+
'a public key cannot sign, and the failure names why',
|
|
95
|
+
);
|
|
96
|
+
t.end();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
t.test('a generated key verifies nothing rather than the development key', async t => {
|
|
100
|
+
// A gateway that generated its own keys must not fall back to the committed
|
|
101
|
+
// development key: that would accept a token anybody can mint with the
|
|
102
|
+
// repository in hand.
|
|
103
|
+
t.equal(
|
|
104
|
+
await verifyGrant(await mintGrant({cap: ['calls']}), {generate: {alg: 'ES384'}}),
|
|
105
|
+
undefined,
|
|
106
|
+
'a key that exists only in the process that made it verifies no grant',
|
|
107
|
+
);
|
|
108
|
+
t.end();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
t.test('the gateway verifies with the key it signs MLE with, unless one is named', t => {
|
|
112
|
+
t.same(
|
|
113
|
+
grantKeyFrom({sign: {env: 'GATEWAY_SIGN_KEY'}}),
|
|
114
|
+
{env: 'GATEWAY_SIGN_KEY'},
|
|
115
|
+
'the sign key by default',
|
|
116
|
+
);
|
|
117
|
+
t.same(
|
|
118
|
+
grantKeyFrom({sign: {env: 'GATEWAY_SIGN_KEY'}, grant: {key: devSignKey}}),
|
|
119
|
+
devSignKey,
|
|
120
|
+
'gateway.grant.key wins when a deployment names one',
|
|
121
|
+
);
|
|
122
|
+
t.equal(grantKeyFrom({}), undefined, 'and no key at all means no verification');
|
|
123
|
+
t.end();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
t.test('a ttl reads the way a person writes one', t => {
|
|
127
|
+
t.equal(parseTtl(undefined), DEFAULT_TTL_SECONDS, 'absent is the default');
|
|
128
|
+
t.equal(parseTtl('45'), 45, 'a bare number is seconds');
|
|
129
|
+
t.equal(parseTtl('45s'), 45);
|
|
130
|
+
t.equal(parseTtl('15m'), 900);
|
|
131
|
+
t.equal(parseTtl('2h'), 7200);
|
|
132
|
+
t.throws(
|
|
133
|
+
() => parseTtl('15 minutes'),
|
|
134
|
+
/Unrecognized --ttl/,
|
|
135
|
+
'anything else is refused, not guessed',
|
|
136
|
+
);
|
|
137
|
+
t.throws(() => parseTtl('0'), /at least a second/);
|
|
138
|
+
t.end();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
t.test('an env key that is not a JWK is reported rather than used', async t => {
|
|
142
|
+
process.env['BLONG_TEST_GRANT_KEY'] = 'not json';
|
|
143
|
+
t.teardown(() => delete process.env['BLONG_TEST_GRANT_KEY']);
|
|
144
|
+
await t.rejects(mintGrant({cap: ['calls'], key: {env: 'BLONG_TEST_GRANT_KEY'}}), /valid JSON/);
|
|
145
|
+
t.equal(
|
|
146
|
+
await verifyGrant('anything', {env: 'BLONG_TEST_GRANT_KEY'}),
|
|
147
|
+
undefined,
|
|
148
|
+
'verification refuses it too',
|
|
149
|
+
);
|
|
150
|
+
t.end();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
t.test('a grant asks for calls through the one seam the gateway uses', async t => {
|
|
154
|
+
const token = await mintGrant({cap: ['calls'], ttlSeconds: 60});
|
|
155
|
+
t.equal(await grantedCalls(token, undefined), true, 'a granted request asks for calls');
|
|
156
|
+
t.equal(
|
|
157
|
+
await grantedCalls(undefined, undefined),
|
|
158
|
+
false,
|
|
159
|
+
'a request with no grant asks for nothing',
|
|
160
|
+
);
|
|
161
|
+
t.equal(
|
|
162
|
+
await grantedCalls('not-a-token', undefined),
|
|
163
|
+
false,
|
|
164
|
+
'and a tampered one asks for nothing',
|
|
165
|
+
);
|
|
166
|
+
const expired = await verifyGrant(await mintGrant({cap: ['calls'], ttlSeconds: 1}), undefined);
|
|
167
|
+
t.ok((expired?.exp ?? 0) > 0, 'a short-lived token still verifies while it is live');
|
|
168
|
+
});
|
package/src/grant.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short-lived grants: one signed token that asks for one capability, on one
|
|
3
|
+
* request, for a few minutes.
|
|
4
|
+
*
|
|
5
|
+
* Troubleshooting a *running* system needs data that is far too expensive — or
|
|
6
|
+
* too revealing — to produce for every request: the calls of a flow, the payloads
|
|
7
|
+
* of a message, the debug records of a component. The alternative to always
|
|
8
|
+
* collecting them is a switch that can be turned on for the next request, which
|
|
9
|
+
* is what this is:
|
|
10
|
+
*
|
|
11
|
+
* blong grant calls --ttl 15m
|
|
12
|
+
* curl -H 'x-blong-grant: <token>' ...
|
|
13
|
+
*
|
|
14
|
+
* - **Short-lived**: `exp` is enforced at verification, so a leaked token is
|
|
15
|
+
* worth a quarter of an hour, not a session.
|
|
16
|
+
* - **One capability per token** (or a few, in `cap`), so granting one thing
|
|
17
|
+
* never implies another.
|
|
18
|
+
* - **Signed with the gateway's own key** — the one it can verify with and a
|
|
19
|
+
* client cannot mint with. A deployment sets `GATEWAY_SIGN_KEY`; a checkout
|
|
20
|
+
* uses the committed development key, which is what makes the command work
|
|
21
|
+
* locally with no setup.
|
|
22
|
+
*
|
|
23
|
+
* The token is *not* forwarded: the gateway verifies it at its earliest hook and
|
|
24
|
+
* republishes the answer as a plain capability marker in `$meta.forward`
|
|
25
|
+
* (`callTrace.ts`), so a downstream process reads a decided capability instead of
|
|
26
|
+
* a credential, and needs no key of its own. Capability #2 is added by naming it
|
|
27
|
+
* in {@link Capability} and asking for it where its work happens — the transport,
|
|
28
|
+
* the verification and the propagation are already shared.
|
|
29
|
+
*
|
|
30
|
+
* ## Failure is "no grant"
|
|
31
|
+
*
|
|
32
|
+
* An absent, malformed, expired or wrongly-signed token returns `undefined`
|
|
33
|
+
* rather than throwing. These tokens arrive on a header anybody can set, so a
|
|
34
|
+
* bad one is a request with no capabilities — never a rejected request. A
|
|
35
|
+
* telemetry switch must not be able to fail the call it was meant to illuminate.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import {importJWK, jwtVerify, SignJWT, type JWK} from 'jose';
|
|
39
|
+
import {CALLS_CAPABILITY} from './callTrace.ts';
|
|
40
|
+
import {devSignKey} from './devKeys.ts';
|
|
41
|
+
|
|
42
|
+
/** The capabilities a grant may ask for. One name per capability, never a wildcard. */
|
|
43
|
+
export type Capability = 'calls';
|
|
44
|
+
|
|
45
|
+
/** Every capability this build knows, so an unknown claim is dropped rather than trusted. */
|
|
46
|
+
const KNOWN: readonly Capability[] = ['calls'];
|
|
47
|
+
|
|
48
|
+
/** How a key is named to the framework. */
|
|
49
|
+
export type GrantKey = {env: string} | {generate: unknown} | JWK;
|
|
50
|
+
|
|
51
|
+
/** Fifteen minutes: long enough to reproduce a failure, short enough to forget. */
|
|
52
|
+
export const DEFAULT_TTL_SECONDS = 900;
|
|
53
|
+
|
|
54
|
+
/** The claims a grant carries. Nothing else is read back out of the token. */
|
|
55
|
+
export interface GrantClaims {
|
|
56
|
+
cap: Capability[];
|
|
57
|
+
/** Expiry, in seconds since the epoch, as JWT spells it. */
|
|
58
|
+
exp?: number;
|
|
59
|
+
iat?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isCapability(value: unknown): value is Capability {
|
|
63
|
+
return typeof value === 'string' && (KNOWN as readonly string[]).includes(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a key spec to a JWK.
|
|
68
|
+
*
|
|
69
|
+
* `{env}` exists so a deployment can keep the key material in its secret
|
|
70
|
+
* configuration rather than in the merged config a `/api/sys/config` dump would
|
|
71
|
+
* print; the literal `devSignKey` is what a checkout falls back to.
|
|
72
|
+
*/
|
|
73
|
+
function resolveKey(key: GrantKey | undefined): JWK | undefined {
|
|
74
|
+
if (key === undefined) {
|
|
75
|
+
return devSignKey as JWK;
|
|
76
|
+
}
|
|
77
|
+
if ('generate' in key) {
|
|
78
|
+
// A generated key exists only in the process that made it: a gateway whose
|
|
79
|
+
// keys were generated (no `GATEWAY_SIGN_KEY`, no configured key) verifies no
|
|
80
|
+
// grant at all, and in particular does not fall back to the committed
|
|
81
|
+
// development key and start accepting tokens anybody could mint.
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
if ('env' in key) {
|
|
85
|
+
const value = process.env[key.env];
|
|
86
|
+
if (!value) return undefined;
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(value) as JWK;
|
|
89
|
+
} catch {
|
|
90
|
+
throw new Error(`Grant key "${key.env}" is set but does not contain valid JSON`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return key;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The signing algorithm the JWK names, defaulting to the framework's own. */
|
|
97
|
+
function algorithmOf(jwk: JWK): string {
|
|
98
|
+
return typeof jwk.alg === 'string' ? jwk.alg : 'ES384';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The public half of a JWK, which is all verification needs. */
|
|
102
|
+
function publicOf(jwk: JWK): JWK {
|
|
103
|
+
const {d: _d, p: _p, q: _q, dp: _dp, dq: _dq, qi: _qi, ...rest} = jwk;
|
|
104
|
+
return rest as JWK;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Mint a grant for `cap`, good for `ttlSeconds`.
|
|
109
|
+
*
|
|
110
|
+
* Throws when the key has no private half: minting a token nobody can verify is
|
|
111
|
+
* a worse answer than saying so, and reaching here without one means the
|
|
112
|
+
* deployment's key is configured as public-only.
|
|
113
|
+
*/
|
|
114
|
+
export async function mintGrant({
|
|
115
|
+
cap,
|
|
116
|
+
ttlSeconds = DEFAULT_TTL_SECONDS,
|
|
117
|
+
key,
|
|
118
|
+
}: {
|
|
119
|
+
cap: Capability[];
|
|
120
|
+
ttlSeconds?: number;
|
|
121
|
+
key?: GrantKey;
|
|
122
|
+
}): Promise<string> {
|
|
123
|
+
const jwk = resolveKey(key);
|
|
124
|
+
if (!jwk) {
|
|
125
|
+
throw new Error('No grant key is configured (set GATEWAY_SIGN_KEY or pass --key)');
|
|
126
|
+
}
|
|
127
|
+
if (jwk.d === undefined) {
|
|
128
|
+
throw new Error('The grant key has no private half, so no grant can be minted with it');
|
|
129
|
+
}
|
|
130
|
+
const alg = algorithmOf(jwk);
|
|
131
|
+
const signer = await importJWK(jwk, alg);
|
|
132
|
+
return new SignJWT({cap})
|
|
133
|
+
.setProtectedHeader({alg, ...(jwk.kid === undefined ? {} : {kid: jwk.kid})})
|
|
134
|
+
.setIssuedAt()
|
|
135
|
+
.setExpirationTime(`${ttlSeconds}s`)
|
|
136
|
+
.sign(signer);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Verify a grant and read its capabilities.
|
|
141
|
+
*
|
|
142
|
+
* `undefined` for anything that does not verify — absent, malformed, expired,
|
|
143
|
+
* signed by another key, or naming a capability this build does not know. The
|
|
144
|
+
* caller treats that as "no capabilities", never as an error.
|
|
145
|
+
*/
|
|
146
|
+
export async function verifyGrant(
|
|
147
|
+
token: string | undefined,
|
|
148
|
+
key?: GrantKey,
|
|
149
|
+
): Promise<GrantClaims | undefined> {
|
|
150
|
+
if (typeof token !== 'string' || token.length === 0) return undefined;
|
|
151
|
+
let jwk: JWK | undefined;
|
|
152
|
+
try {
|
|
153
|
+
jwk = resolveKey(key);
|
|
154
|
+
} catch {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
if (!jwk || jwk.d === undefined) return undefined;
|
|
158
|
+
try {
|
|
159
|
+
const {payload} = await jwtVerify(token, await importJWK(publicOf(jwk), algorithmOf(jwk)));
|
|
160
|
+
const cap = Array.isArray(payload.cap) ? payload.cap.filter(isCapability) : [];
|
|
161
|
+
return {
|
|
162
|
+
cap,
|
|
163
|
+
exp: typeof payload.exp === 'number' ? payload.exp : undefined,
|
|
164
|
+
iat: typeof payload.iat === 'number' ? payload.iat : undefined,
|
|
165
|
+
};
|
|
166
|
+
} catch {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** True when `grant` asked for `capability`. */
|
|
172
|
+
export function granted(grant: GrantClaims | undefined, capability: Capability): boolean {
|
|
173
|
+
return grant?.cap.includes(capability) === true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Where the gateway's verification key comes from.
|
|
178
|
+
*
|
|
179
|
+
* `gateway.grant.key` when a deployment names one, else the gateway's sign key
|
|
180
|
+
* spec — the key the gateway already resolves for MLE, whose private half stays
|
|
181
|
+
* on the server. Reading it here rather than in `Gateway.ts` keeps the two
|
|
182
|
+
* callers (a running gateway and the CLI that mints for it) on one rule.
|
|
183
|
+
*/
|
|
184
|
+
export function grantKeyFrom(config: unknown): GrantKey | undefined {
|
|
185
|
+
const grant = (config as {grant?: {key?: GrantKey}} | undefined)?.grant;
|
|
186
|
+
if (grant?.key !== undefined) return grant.key;
|
|
187
|
+
const sign = (config as {sign?: GrantKey} | undefined)?.sign;
|
|
188
|
+
return sign === undefined ? undefined : sign;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Whether a grant asks for a flow's calls.
|
|
193
|
+
*
|
|
194
|
+
* The whole of what the gateway's earliest hook asks a grant: verify it once, and
|
|
195
|
+
* turn the answer into the capability the flow is served under. Named rather than
|
|
196
|
+
* inlined there so the seam can be tested without an HTTP server — the token's
|
|
197
|
+
* checks and the capability's name are what could drift, and neither needs a
|
|
198
|
+
* request to prove.
|
|
199
|
+
*/
|
|
200
|
+
export async function grantedCalls(
|
|
201
|
+
token: string | undefined,
|
|
202
|
+
key: GrantKey | undefined,
|
|
203
|
+
): Promise<boolean> {
|
|
204
|
+
return granted(await verifyGrant(token, key), CALLS_CAPABILITY);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Read a duration the way a person writes one: `45`, `45s`, `15m`, `2h`.
|
|
209
|
+
*
|
|
210
|
+
* A bare number is seconds, which is the unit the token itself uses. Anything
|
|
211
|
+
* else is refused rather than guessed at — a TTL that silently means minutes
|
|
212
|
+
* where the operator meant hours is a grant that outlives the investigation it
|
|
213
|
+
* was minted for.
|
|
214
|
+
*/
|
|
215
|
+
export function parseTtl(value: unknown): number {
|
|
216
|
+
if (value === undefined) return DEFAULT_TTL_SECONDS;
|
|
217
|
+
const text = String(value).trim();
|
|
218
|
+
const match = /^(\d+)(s|m|h)?$/.exec(text);
|
|
219
|
+
if (!match) {
|
|
220
|
+
throw new Error(`Unrecognized --ttl "${text}": expected seconds, or a number with s/m/h`);
|
|
221
|
+
}
|
|
222
|
+
const amount = Number(match[1]);
|
|
223
|
+
const unit = match[2] ?? 's';
|
|
224
|
+
const seconds = unit === 'h' ? amount * 3600 : unit === 'm' ? amount * 60 : amount;
|
|
225
|
+
if (seconds < 1)
|
|
226
|
+
throw new Error(`Unrecognized --ttl "${text}": a grant must last at least a second`);
|
|
227
|
+
return seconds;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* `blong grant <capability> [--ttl=15m] [--key=<jwk|ENV_VAR>]`.
|
|
232
|
+
*
|
|
233
|
+
* Prints the token on stdout — and nothing else, so it can be used in a shell
|
|
234
|
+
* substitution — and exits non-zero with a reason on stderr when it cannot be
|
|
235
|
+
* minted. It does not load a suite: minting is one signature over the key the
|
|
236
|
+
* deployment already has, and a command that had to boot an application to
|
|
237
|
+
* produce a token would be unusable exactly when it is wanted (a running
|
|
238
|
+
* deployment whose behaviour is in question).
|
|
239
|
+
*/
|
|
240
|
+
export async function runGrantCommand(
|
|
241
|
+
positionals: string[],
|
|
242
|
+
flags: {ttl?: unknown; key?: unknown} | Record<string, unknown>,
|
|
243
|
+
): Promise<void> {
|
|
244
|
+
const {ttl, key: keyFlag} = flags as {ttl?: unknown; key?: unknown};
|
|
245
|
+
const requested = positionals.slice(1).filter(name => name.length > 0);
|
|
246
|
+
const unknown = requested.filter(name => !isCapability(name));
|
|
247
|
+
if (requested.length === 0 || unknown.length > 0) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`Usage: blong grant <capability> [--ttl=15m]\n` +
|
|
250
|
+
`Known capabilities: ${KNOWN.join(', ')}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const key = typeof keyFlag === 'string' ? parseKeyFlag(keyFlag) : undefined;
|
|
254
|
+
const token = await mintGrant({
|
|
255
|
+
cap: requested as Capability[],
|
|
256
|
+
ttlSeconds: parseTtl(ttl),
|
|
257
|
+
...(key === undefined ? {} : {key}),
|
|
258
|
+
});
|
|
259
|
+
process.stdout.write(`${token}\n`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Read `--key`: either an env var name holding the JWK, or the JWK itself.
|
|
264
|
+
*
|
|
265
|
+
* The name is the form a deployment uses (`--key=GATEWAY_SIGN_KEY`, beside the
|
|
266
|
+
* variable the gateway reads), and the literal is the form an operator uses when
|
|
267
|
+
* the key is not in the environment they are minting from.
|
|
268
|
+
*/
|
|
269
|
+
function parseKeyFlag(value: string): GrantKey {
|
|
270
|
+
const text = value.trim();
|
|
271
|
+
if (text.startsWith('{')) return JSON.parse(text) as JWK;
|
|
272
|
+
return {env: text};
|
|
273
|
+
}
|