@anby/platform-sdk 0.1.1 → 0.7.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/dist/cjs/apps/publish.d.ts +23 -0
- package/dist/cjs/apps/publish.d.ts.map +1 -1
- package/dist/cjs/apps/publish.js +65 -5
- package/dist/cjs/apps/publish.js.map +1 -1
- package/dist/cjs/auth/index.d.ts +33 -3
- package/dist/cjs/auth/index.d.ts.map +1 -1
- package/dist/cjs/auth/index.js +105 -24
- package/dist/cjs/auth/index.js.map +1 -1
- package/dist/cjs/bootstrap/cache.d.ts +4 -0
- package/dist/cjs/bootstrap/cache.d.ts.map +1 -0
- package/dist/cjs/bootstrap/cache.js +52 -0
- package/dist/cjs/bootstrap/cache.js.map +1 -0
- package/dist/cjs/bootstrap/index.d.ts +79 -0
- package/dist/cjs/bootstrap/index.d.ts.map +1 -0
- package/dist/cjs/bootstrap/index.js +280 -0
- package/dist/cjs/bootstrap/index.js.map +1 -0
- package/dist/cjs/bootstrap/types.d.ts +53 -0
- package/dist/cjs/bootstrap/types.d.ts.map +1 -0
- package/dist/cjs/bootstrap/types.js +14 -0
- package/dist/cjs/bootstrap/types.js.map +1 -0
- package/dist/cjs/events/http-transport.d.ts +38 -0
- package/dist/cjs/events/http-transport.d.ts.map +1 -0
- package/dist/cjs/events/http-transport.js +63 -0
- package/dist/cjs/events/http-transport.js.map +1 -0
- package/dist/cjs/events/index.d.ts +49 -0
- package/dist/cjs/events/index.d.ts.map +1 -1
- package/dist/cjs/events/index.js +14 -1
- package/dist/cjs/events/index.js.map +1 -1
- package/dist/cjs/index.d.ts +6 -3
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +30 -11
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/vite/index.d.ts +20 -0
- package/dist/cjs/vite/index.d.ts.map +1 -0
- package/dist/cjs/vite/index.js +154 -0
- package/dist/cjs/vite/index.js.map +1 -0
- package/dist/esm/apps/publish.js +31 -5
- package/dist/esm/apps/publish.js.map +1 -1
- package/dist/esm/auth/index.js +102 -23
- package/dist/esm/auth/index.js.map +1 -1
- package/dist/esm/bootstrap/cache.js +48 -0
- package/dist/esm/bootstrap/cache.js.map +1 -0
- package/dist/esm/bootstrap/index.js +272 -0
- package/dist/esm/bootstrap/index.js.map +1 -0
- package/dist/esm/bootstrap/types.js +11 -0
- package/dist/esm/bootstrap/types.js.map +1 -0
- package/dist/esm/events/http-transport.js +59 -0
- package/dist/esm/events/http-transport.js.map +1 -0
- package/dist/esm/events/index.js +14 -1
- package/dist/esm/events/index.js.map +1 -1
- package/dist/esm/index.js +8 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/vite/index.js +151 -0
- package/dist/esm/vite/index.js.map +1 -0
- package/package.json +14 -1
- package/src/apps/publish.ts +45 -6
- package/src/auth/index.test.ts +249 -0
- package/src/auth/index.ts +126 -32
- package/src/bootstrap/cache.ts +60 -0
- package/src/bootstrap/index.test.ts +277 -0
- package/src/bootstrap/index.ts +350 -0
- package/src/bootstrap/types.ts +56 -0
- package/src/events/http-transport.test.ts +135 -0
- package/src/events/http-transport.ts +77 -0
- package/src/events/index.ts +73 -2
- package/src/index.ts +29 -1
- package/src/vite/index.ts +195 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { generateKeyPairSync } from 'node:crypto';
|
|
6
|
+
import {
|
|
7
|
+
bootstrapFromToken,
|
|
8
|
+
parseAppToken,
|
|
9
|
+
ANBY_TOKEN_PREFIX,
|
|
10
|
+
_resetBootstrapForTests,
|
|
11
|
+
} from './index.js';
|
|
12
|
+
import type { DiscoveryResponse } from './types.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Test fixtures
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
// Generate one Ed25519 keypair for the per-app identity
|
|
19
|
+
const { privateKey: appPrivKey } = generateKeyPairSync('ed25519');
|
|
20
|
+
const appPrivKeyPem = appPrivKey
|
|
21
|
+
.export({ type: 'pkcs8', format: 'pem' })
|
|
22
|
+
.toString();
|
|
23
|
+
|
|
24
|
+
// Generate one RSA keypair for the auth-service public key
|
|
25
|
+
const { privateKey: rsaPriv, publicKey: rsaPub } = generateKeyPairSync('rsa', {
|
|
26
|
+
modulusLength: 2048,
|
|
27
|
+
});
|
|
28
|
+
const rsaPubPem = rsaPub.export({ type: 'spki', format: 'pem' }).toString();
|
|
29
|
+
|
|
30
|
+
function makeToken(overrides: Record<string, unknown> = {}): string {
|
|
31
|
+
const payload = {
|
|
32
|
+
v: 1,
|
|
33
|
+
appId: 'com.test.example',
|
|
34
|
+
platformUrl: 'https://anby.test',
|
|
35
|
+
privateKey: appPrivKeyPem,
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
return ANBY_TOKEN_PREFIX + Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeDiscovery(): DiscoveryResponse {
|
|
42
|
+
return {
|
|
43
|
+
v: 1,
|
|
44
|
+
platform: { name: 'anby', version: '0.4.0' },
|
|
45
|
+
endpoints: {
|
|
46
|
+
authPublicKeyUrl: 'https://anby.test/auth/public-key',
|
|
47
|
+
scopedTokenUrl: 'https://anby.test/registry/scoped-token',
|
|
48
|
+
entityTokenPublicKeyUrl: 'https://anby.test/registry/entity-token/public-key',
|
|
49
|
+
gatewayUrl: 'https://anby.test',
|
|
50
|
+
registryUrl: 'https://anby.test/registry',
|
|
51
|
+
tenantServiceUrl: 'https://anby.test/tenants',
|
|
52
|
+
eventRouterUrl: 'https://anby.test/events',
|
|
53
|
+
},
|
|
54
|
+
cacheTtlSeconds: 86400,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeFetch(opts: {
|
|
59
|
+
discovery?: DiscoveryResponse;
|
|
60
|
+
authPublicKey?: string;
|
|
61
|
+
fail?: 'discovery' | 'public-key' | 'all';
|
|
62
|
+
}): typeof fetch {
|
|
63
|
+
return (async (url: string | URL | Request) => {
|
|
64
|
+
const u = typeof url === 'string' ? url : url.toString();
|
|
65
|
+
if (opts.fail === 'all') {
|
|
66
|
+
throw new Error('network error');
|
|
67
|
+
}
|
|
68
|
+
if (u.includes('/registry/discovery')) {
|
|
69
|
+
if (opts.fail === 'discovery') throw new Error('discovery down');
|
|
70
|
+
return new Response(JSON.stringify(opts.discovery ?? makeDiscovery()), {
|
|
71
|
+
status: 200,
|
|
72
|
+
headers: { 'content-type': 'application/json' },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (u.includes('/auth/public-key')) {
|
|
76
|
+
if (opts.fail === 'public-key') throw new Error('auth down');
|
|
77
|
+
return new Response(opts.authPublicKey ?? rsaPubPem, {
|
|
78
|
+
status: 200,
|
|
79
|
+
headers: { 'content-type': 'text/plain' },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return new Response('not found', { status: 404 });
|
|
83
|
+
}) as typeof fetch;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Tests
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
describe('parseAppToken', () => {
|
|
91
|
+
it('parses a valid token', () => {
|
|
92
|
+
const tok = parseAppToken(makeToken());
|
|
93
|
+
expect(tok.appId).toBe('com.test.example');
|
|
94
|
+
expect(tok.platformUrl).toBe('https://anby.test');
|
|
95
|
+
expect(tok.privateKey).toContain('PRIVATE KEY');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('strips trailing slash from platformUrl', () => {
|
|
99
|
+
const tok = parseAppToken(makeToken({ platformUrl: 'https://anby.test/' }));
|
|
100
|
+
expect(tok.platformUrl).toBe('https://anby.test');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects empty token', () => {
|
|
104
|
+
expect(() => parseAppToken('')).toThrow();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('rejects token without anby_v1_ prefix', () => {
|
|
108
|
+
expect(() => parseAppToken('garbage')).toThrow(/anby_v1_/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('rejects token with invalid base64', () => {
|
|
112
|
+
expect(() => parseAppToken('anby_v1_!!!not-base64!!!')).toThrow();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('rejects token with malformed JSON', () => {
|
|
116
|
+
const bad = ANBY_TOKEN_PREFIX + Buffer.from('not json').toString('base64url');
|
|
117
|
+
expect(() => parseAppToken(bad)).toThrow();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('rejects token missing required fields', () => {
|
|
121
|
+
const bad = ANBY_TOKEN_PREFIX + Buffer.from(JSON.stringify({ v: 1 })).toString('base64url');
|
|
122
|
+
expect(() => parseAppToken(bad)).toThrow(/required fields/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('rejects token with private key that is not PEM', () => {
|
|
126
|
+
const bad = ANBY_TOKEN_PREFIX + Buffer.from(JSON.stringify({
|
|
127
|
+
v: 1,
|
|
128
|
+
appId: 'x',
|
|
129
|
+
platformUrl: 'https://x',
|
|
130
|
+
privateKey: 'not a pem',
|
|
131
|
+
})).toString('base64url');
|
|
132
|
+
expect(() => parseAppToken(bad)).toThrow(/not a PEM/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('rejects token with wrong version', () => {
|
|
136
|
+
const bad = ANBY_TOKEN_PREFIX + Buffer.from(JSON.stringify({
|
|
137
|
+
v: 2,
|
|
138
|
+
appId: 'x',
|
|
139
|
+
platformUrl: 'https://x',
|
|
140
|
+
privateKey: '-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----',
|
|
141
|
+
})).toString('base64url');
|
|
142
|
+
expect(() => parseAppToken(bad)).toThrow(/required fields/);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('bootstrapFromToken — happy path', () => {
|
|
147
|
+
let cacheDir: string;
|
|
148
|
+
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
_resetBootstrapForTests();
|
|
151
|
+
cacheDir = mkdtempSync(join(tmpdir(), 'anby-bootstrap-'));
|
|
152
|
+
});
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
rmSync(cacheDir, { recursive: true, force: true });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('fetches discovery + auth public key, configures SDK', async () => {
|
|
158
|
+
await bootstrapFromToken({
|
|
159
|
+
appToken: makeToken(),
|
|
160
|
+
cacheDir,
|
|
161
|
+
fetchImpl: makeFetch({}),
|
|
162
|
+
});
|
|
163
|
+
// Confirm cache file written
|
|
164
|
+
const fs = await import('node:fs/promises');
|
|
165
|
+
const files = await fs.readdir(cacheDir);
|
|
166
|
+
expect(files.some((f) => f.startsWith('bootstrap-'))).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('writes a cache entry that contains discovery + public key but no private key', async () => {
|
|
170
|
+
await bootstrapFromToken({
|
|
171
|
+
appToken: makeToken(),
|
|
172
|
+
cacheDir,
|
|
173
|
+
fetchImpl: makeFetch({}),
|
|
174
|
+
});
|
|
175
|
+
const fs = await import('node:fs/promises');
|
|
176
|
+
const files = await fs.readdir(cacheDir);
|
|
177
|
+
const cacheFile = files.find((f) => f.startsWith('bootstrap-'))!;
|
|
178
|
+
const raw = await fs.readFile(join(cacheDir, cacheFile), 'utf-8');
|
|
179
|
+
const cache = JSON.parse(raw);
|
|
180
|
+
expect(cache.discovery.v).toBe(1);
|
|
181
|
+
expect(cache.authPublicKeyPem).toContain('BEGIN PUBLIC KEY');
|
|
182
|
+
// CRITICAL: cache must NOT contain the private key
|
|
183
|
+
expect(JSON.stringify(cache)).not.toContain('PRIVATE KEY');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('bootstrapFromToken — offline cold start', () => {
|
|
188
|
+
let cacheDir: string;
|
|
189
|
+
|
|
190
|
+
beforeEach(() => {
|
|
191
|
+
_resetBootstrapForTests();
|
|
192
|
+
cacheDir = mkdtempSync(join(tmpdir(), 'anby-bootstrap-'));
|
|
193
|
+
});
|
|
194
|
+
afterEach(() => {
|
|
195
|
+
rmSync(cacheDir, { recursive: true, force: true });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('falls back to disk cache when discovery fetch fails and cache exists', async () => {
|
|
199
|
+
// Warm the cache with one successful bootstrap
|
|
200
|
+
await bootstrapFromToken({
|
|
201
|
+
appToken: makeToken(),
|
|
202
|
+
cacheDir,
|
|
203
|
+
fetchImpl: makeFetch({}),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Now bootstrap with all fetches failing — should use cache
|
|
207
|
+
await expect(
|
|
208
|
+
bootstrapFromToken({
|
|
209
|
+
appToken: makeToken(),
|
|
210
|
+
cacheDir,
|
|
211
|
+
fetchImpl: makeFetch({ fail: 'all' }),
|
|
212
|
+
}),
|
|
213
|
+
).resolves.not.toThrow();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('throws when discovery fails and no cache exists', async () => {
|
|
217
|
+
await expect(
|
|
218
|
+
bootstrapFromToken({
|
|
219
|
+
appToken: makeToken(),
|
|
220
|
+
cacheDir,
|
|
221
|
+
fetchImpl: makeFetch({ fail: 'all' }),
|
|
222
|
+
}),
|
|
223
|
+
).rejects.toThrow(/no cache available/);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('bootstrapFromToken — error cases', () => {
|
|
228
|
+
let cacheDir: string;
|
|
229
|
+
|
|
230
|
+
beforeEach(() => {
|
|
231
|
+
_resetBootstrapForTests();
|
|
232
|
+
cacheDir = mkdtempSync(join(tmpdir(), 'anby-bootstrap-'));
|
|
233
|
+
});
|
|
234
|
+
afterEach(() => {
|
|
235
|
+
rmSync(cacheDir, { recursive: true, force: true });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('CRITICAL: rejects auth-public-key endpoint that returns PRIVATE KEY material', async () => {
|
|
239
|
+
const fetchImpl = makeFetch({
|
|
240
|
+
authPublicKey: '-----BEGIN PRIVATE KEY-----\nLEAK\n-----END PRIVATE KEY-----',
|
|
241
|
+
});
|
|
242
|
+
await expect(
|
|
243
|
+
bootstrapFromToken({
|
|
244
|
+
appToken: makeToken(),
|
|
245
|
+
cacheDir,
|
|
246
|
+
fetchImpl,
|
|
247
|
+
}),
|
|
248
|
+
).rejects.toThrow(/PRIVATE KEY/);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('rejects auth-public-key endpoint that returns non-PEM content', async () => {
|
|
252
|
+
const fetchImpl = makeFetch({ authPublicKey: 'not a pem' });
|
|
253
|
+
await expect(
|
|
254
|
+
bootstrapFromToken({
|
|
255
|
+
appToken: makeToken(),
|
|
256
|
+
cacheDir,
|
|
257
|
+
fetchImpl,
|
|
258
|
+
}),
|
|
259
|
+
).rejects.toThrow(/not a PEM/);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('throws on malformed token before any network call', async () => {
|
|
263
|
+
let fetchCalled = false;
|
|
264
|
+
const fetchImpl = (async () => {
|
|
265
|
+
fetchCalled = true;
|
|
266
|
+
return new Response('', { status: 200 });
|
|
267
|
+
}) as typeof fetch;
|
|
268
|
+
await expect(
|
|
269
|
+
bootstrapFromToken({
|
|
270
|
+
appToken: 'garbage',
|
|
271
|
+
cacheDir,
|
|
272
|
+
fetchImpl,
|
|
273
|
+
}),
|
|
274
|
+
).rejects.toThrow();
|
|
275
|
+
expect(fetchCalled).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Third-party app bootstrap (PLAN-app-bootstrap.md PR2).
|
|
3
|
+
*
|
|
4
|
+
* `bootstrapFromToken({ appToken })` is the SINGLE platform-init call a
|
|
5
|
+
* third-party app needs at boot. Given a connection-string token, it:
|
|
6
|
+
*
|
|
7
|
+
* 1. Parses the token (sync, no network) → appId, platformUrl, privateKey
|
|
8
|
+
* 2. Fetches GET ${platformUrl}/registry/discovery (cached on success)
|
|
9
|
+
* 3. Fetches GET ${endpoints.authPublicKeyUrl} (the user JWT verification key)
|
|
10
|
+
* 4. Configures the SDK's auth, platform, and entity-identity layers
|
|
11
|
+
* 5. Schedules a background refresh at 80% of cacheTtlSeconds
|
|
12
|
+
*
|
|
13
|
+
* After this returns, the app can use `requireAuth()`, `verifyUserJwt()`,
|
|
14
|
+
* `publishEvent()`, the entity client, etc. exactly as if it had been
|
|
15
|
+
* configured via the legacy env-based path.
|
|
16
|
+
*
|
|
17
|
+
* Cache: discovery is cached to disk so cold starts survive a brief
|
|
18
|
+
* registry outage. The token itself is NOT cached — it lives in the env
|
|
19
|
+
* var. Cached entries are per-app and contain only public information
|
|
20
|
+
* (URLs and the auth public key PEM, no secrets).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { configureAuth } from '../auth/index.js';
|
|
24
|
+
import { configurePlatform } from '../config/index.js';
|
|
25
|
+
import { configureAppIdentity } from '../entities/identity.js';
|
|
26
|
+
import { configureEventTransport } from '../events/index.js';
|
|
27
|
+
import { HttpEventTransport } from '../events/http-transport.js';
|
|
28
|
+
import { readCache, writeCache } from './cache.js';
|
|
29
|
+
import {
|
|
30
|
+
ANBY_TOKEN_PREFIX,
|
|
31
|
+
type AnbyAppToken,
|
|
32
|
+
type CachedBootstrap,
|
|
33
|
+
type DiscoveryResponse,
|
|
34
|
+
} from './types.js';
|
|
35
|
+
|
|
36
|
+
export { ANBY_TOKEN_PREFIX, type AnbyAppToken, type DiscoveryResponse };
|
|
37
|
+
|
|
38
|
+
// PLAN-app-bootstrap-phase2 PR3: shared bootstrap state.
|
|
39
|
+
//
|
|
40
|
+
// Module-level cache so multiple callers (entry.server, autoPublishOnBoot,
|
|
41
|
+
// entity-handler) all see the same discovery + identity once any one of
|
|
42
|
+
// them has called bootstrapFromToken. Without this, autoPublishOnBoot
|
|
43
|
+
// would have to read process.env.REGISTRY_URL again — defeating the
|
|
44
|
+
// goal of zero env vars.
|
|
45
|
+
interface BootstrapState {
|
|
46
|
+
promise: Promise<void>;
|
|
47
|
+
discovery?: DiscoveryResponse;
|
|
48
|
+
appToken?: AnbyAppToken;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let _state: BootstrapState | null = null;
|
|
52
|
+
|
|
53
|
+
/** For tests: clears the module-level bootstrap state so a fresh
|
|
54
|
+
* bootstrapFromToken call re-runs from scratch. Not exported from the
|
|
55
|
+
* root barrel. */
|
|
56
|
+
export function _resetBootstrapForTests(): void {
|
|
57
|
+
_state = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Returns the discovery response cached by the most recent successful
|
|
62
|
+
* bootstrapFromToken call. Throws if bootstrap has not yet started.
|
|
63
|
+
*
|
|
64
|
+
* Resolves the in-progress promise if bootstrap is still in flight, so
|
|
65
|
+
* callers can `await getDiscoveredEndpoints()` from anywhere safely.
|
|
66
|
+
*/
|
|
67
|
+
export async function getDiscoveredEndpoints(): Promise<
|
|
68
|
+
DiscoveryResponse['endpoints']
|
|
69
|
+
> {
|
|
70
|
+
if (!_state) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
'bootstrap not started — call bootstrapFromToken() before reading discovery state',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
await _state.promise;
|
|
76
|
+
if (!_state.discovery) {
|
|
77
|
+
throw new Error('bootstrap completed but no discovery cached');
|
|
78
|
+
}
|
|
79
|
+
return _state.discovery.endpoints;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns the registry HOST root (e.g. "http://localhost:3003"), without
|
|
84
|
+
* the /registry path suffix. Use this for callers that already append
|
|
85
|
+
* /registry/... themselves (autoPublishOnBoot, RegistryPublicKeyVerifier).
|
|
86
|
+
*
|
|
87
|
+
* Falls back to discovery.endpoints.registryUrl with /registry stripped
|
|
88
|
+
* if the registryBaseUrl field is missing (older registries that haven't
|
|
89
|
+
* deployed PR3 yet).
|
|
90
|
+
*/
|
|
91
|
+
export async function getDiscoveredRegistryBaseUrl(): Promise<string> {
|
|
92
|
+
const endpoints = await getDiscoveredEndpoints();
|
|
93
|
+
if (endpoints.registryBaseUrl) return endpoints.registryBaseUrl;
|
|
94
|
+
// Backward compat: strip /registry suffix from registryUrl.
|
|
95
|
+
return endpoints.registryUrl.replace(/\/registry\/?$/, '');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface BootstrapOptions {
|
|
99
|
+
appToken: string;
|
|
100
|
+
/** Where to write the disk cache. Default: process.cwd() + "/.anby-cache". */
|
|
101
|
+
cacheDir?: string;
|
|
102
|
+
/** Override the discovery fetch (for tests). */
|
|
103
|
+
fetchImpl?: typeof fetch;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse an `anby_v1_<base64json>` token. Throws on malformed input.
|
|
108
|
+
*
|
|
109
|
+
* Sync, no network. Validates structure but does NOT verify the
|
|
110
|
+
* Ed25519 private key against any server — that happens later when the
|
|
111
|
+
* SDK tries to mint a scoped-token.
|
|
112
|
+
*/
|
|
113
|
+
export function parseAppToken(token: string): AnbyAppToken {
|
|
114
|
+
if (!token || typeof token !== 'string') {
|
|
115
|
+
throw new Error('ANBY_APP_TOKEN is empty');
|
|
116
|
+
}
|
|
117
|
+
if (!token.startsWith(ANBY_TOKEN_PREFIX)) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`ANBY_APP_TOKEN must start with "${ANBY_TOKEN_PREFIX}". Did you paste the right value?`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const b64 = token.slice(ANBY_TOKEN_PREFIX.length);
|
|
123
|
+
|
|
124
|
+
let json: string;
|
|
125
|
+
try {
|
|
126
|
+
json = Buffer.from(b64, 'base64url').toString('utf-8');
|
|
127
|
+
} catch (err) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`ANBY_APP_TOKEN payload is not valid base64url: ${(err as Error).message}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let parsed: unknown;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(json);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`ANBY_APP_TOKEN payload is not valid JSON: ${(err as Error).message}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const obj = parsed as Partial<AnbyAppToken>;
|
|
143
|
+
if (
|
|
144
|
+
obj?.v !== 1 ||
|
|
145
|
+
typeof obj.appId !== 'string' ||
|
|
146
|
+
typeof obj.platformUrl !== 'string' ||
|
|
147
|
+
typeof obj.privateKey !== 'string'
|
|
148
|
+
) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
'ANBY_APP_TOKEN payload missing required fields (v, appId, platformUrl, privateKey)',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (!obj.privateKey.includes('PRIVATE KEY')) {
|
|
154
|
+
throw new Error('ANBY_APP_TOKEN.privateKey is not a PEM');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
v: 1,
|
|
159
|
+
appId: obj.appId,
|
|
160
|
+
platformUrl: obj.platformUrl.replace(/\/$/, ''),
|
|
161
|
+
privateKey: obj.privateKey,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function fetchDiscovery(
|
|
166
|
+
platformUrl: string,
|
|
167
|
+
fetchImpl: typeof fetch,
|
|
168
|
+
): Promise<DiscoveryResponse> {
|
|
169
|
+
const url = `${platformUrl}/registry/discovery`;
|
|
170
|
+
const res = await fetchImpl(url, { headers: { accept: 'application/json' } });
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
throw new Error(`discovery fetch failed: ${res.status} ${res.statusText}`);
|
|
173
|
+
}
|
|
174
|
+
const body = (await res.json()) as DiscoveryResponse;
|
|
175
|
+
if (body?.v !== 1 || !body?.endpoints?.authPublicKeyUrl) {
|
|
176
|
+
throw new Error('discovery response is missing required fields');
|
|
177
|
+
}
|
|
178
|
+
return body;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function fetchAuthPublicKey(
|
|
182
|
+
url: string,
|
|
183
|
+
fetchImpl: typeof fetch,
|
|
184
|
+
): Promise<string> {
|
|
185
|
+
const res = await fetchImpl(url, { headers: { accept: 'text/plain' } });
|
|
186
|
+
if (!res.ok) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`auth-public-key fetch failed: ${res.status} ${res.statusText}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const pem = await res.text();
|
|
192
|
+
// CRITICAL leak check FIRST: a buggy auth-service that accidentally
|
|
193
|
+
// serves a private key on this endpoint must be rejected loudly,
|
|
194
|
+
// before any other validation that might mask the security finding.
|
|
195
|
+
if (pem.includes('PRIVATE KEY')) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
'auth-public-key endpoint returned PRIVATE KEY material — refusing to use it',
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (!pem.includes('BEGIN PUBLIC KEY')) {
|
|
201
|
+
throw new Error('auth-public-key response is not a PEM public key');
|
|
202
|
+
}
|
|
203
|
+
return pem;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The single bootstrap entrypoint a third-party app calls at boot.
|
|
208
|
+
*
|
|
209
|
+
* Idempotent: if called multiple times in the same process (e.g. once
|
|
210
|
+
* from entry.server.tsx and again from a refresh timer), the second call
|
|
211
|
+
* returns the same in-flight promise instead of starting a parallel
|
|
212
|
+
* bootstrap.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* ```ts
|
|
216
|
+
* import { bootstrapFromToken, requireAuth } from '@anby/platform-sdk';
|
|
217
|
+
*
|
|
218
|
+
* await bootstrapFromToken({ appToken: process.env.ANBY_APP_TOKEN! });
|
|
219
|
+
*
|
|
220
|
+
* app.get('/api/widgets', requireAuth(), handler);
|
|
221
|
+
* ```
|
|
222
|
+
*/
|
|
223
|
+
export function bootstrapFromToken(opts: BootstrapOptions): Promise<void> {
|
|
224
|
+
// PLAN-app-bootstrap-phase2 PR3: dedupe concurrent calls. Multiple
|
|
225
|
+
// entry points (entry.server, autoPublishOnBoot, refresh timer) can
|
|
226
|
+
// all await the same shared promise without spawning parallel work.
|
|
227
|
+
if (_state) return _state.promise;
|
|
228
|
+
_state = {
|
|
229
|
+
promise: doBootstrap(opts).catch((err) => {
|
|
230
|
+
// Failed bootstrap clears state so the next call retries.
|
|
231
|
+
_state = null;
|
|
232
|
+
throw err;
|
|
233
|
+
}),
|
|
234
|
+
};
|
|
235
|
+
return _state.promise;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function doBootstrap(opts: BootstrapOptions): Promise<void> {
|
|
239
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
240
|
+
if (!fetchImpl) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
'No fetch implementation available. Pass opts.fetchImpl or run on Node 18+.',
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
const cacheDir = opts.cacheDir ?? `${process.cwd()}/.anby-cache`;
|
|
246
|
+
|
|
247
|
+
// 1. Parse the token (sync, no network)
|
|
248
|
+
const token = parseAppToken(opts.appToken);
|
|
249
|
+
if (_state) _state.appToken = token;
|
|
250
|
+
|
|
251
|
+
// 2. Try fetching fresh discovery + auth public key. Fall back to disk
|
|
252
|
+
// cache if the network is unreachable on cold start.
|
|
253
|
+
let discovery: DiscoveryResponse;
|
|
254
|
+
let authPublicKeyPem: string;
|
|
255
|
+
let usedCache = false;
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
discovery = await fetchDiscovery(token.platformUrl, fetchImpl);
|
|
259
|
+
authPublicKeyPem = await fetchAuthPublicKey(
|
|
260
|
+
discovery.endpoints.authPublicKeyUrl,
|
|
261
|
+
fetchImpl,
|
|
262
|
+
);
|
|
263
|
+
} catch (fetchErr) {
|
|
264
|
+
const cached = await readCache(cacheDir, token.appId);
|
|
265
|
+
if (!cached) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`bootstrap failed and no cache available: ${(fetchErr as Error).message}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
discovery = cached.discovery;
|
|
271
|
+
authPublicKeyPem = cached.authPublicKeyPem;
|
|
272
|
+
usedCache = true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Cache discovery into the shared module state so other modules can
|
|
276
|
+
// read it via getDiscoveredEndpoints() / getDiscoveredRegistryBaseUrl().
|
|
277
|
+
if (_state) _state.discovery = discovery;
|
|
278
|
+
|
|
279
|
+
// 3. Configure SDK subsystems
|
|
280
|
+
configureAuth({ jwtPublicKey: authPublicKeyPem });
|
|
281
|
+
configurePlatform({
|
|
282
|
+
appId: token.appId,
|
|
283
|
+
registryUrl: discovery.endpoints.registryUrl,
|
|
284
|
+
});
|
|
285
|
+
configureAppIdentity({
|
|
286
|
+
appId: token.appId,
|
|
287
|
+
privateKeyPem: token.privateKey,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// PLAN-app-bootstrap-phase2 PR3: auto-wire HttpEventTransport so dev
|
|
291
|
+
// calls to publishEvent() work without any manual configuration. The
|
|
292
|
+
// events endpoint URL comes from discovery — fall back to deriving it
|
|
293
|
+
// from registryBaseUrl if older discovery responses lack the explicit
|
|
294
|
+
// eventsUrl field.
|
|
295
|
+
const eventsUrl =
|
|
296
|
+
discovery.endpoints.eventsUrl ??
|
|
297
|
+
`${discovery.endpoints.registryBaseUrl ?? discovery.endpoints.registryUrl.replace(/\/registry\/?$/, '')}/registry/events`;
|
|
298
|
+
configureEventTransport(
|
|
299
|
+
new HttpEventTransport({
|
|
300
|
+
endpoint: eventsUrl,
|
|
301
|
+
identity: { appId: token.appId, privateKeyPem: token.privateKey },
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// 4. Persist to cache (only on successful network fetch — don't
|
|
306
|
+
// overwrite a fresh cache with a stale-cache value)
|
|
307
|
+
if (!usedCache) {
|
|
308
|
+
const fetchedAt = new Date();
|
|
309
|
+
const staleAt = new Date(fetchedAt.getTime() + discovery.cacheTtlSeconds * 1000);
|
|
310
|
+
const entry: CachedBootstrap = {
|
|
311
|
+
fetchedAt: fetchedAt.toISOString(),
|
|
312
|
+
staleAt: staleAt.toISOString(),
|
|
313
|
+
discovery,
|
|
314
|
+
authPublicKeyPem,
|
|
315
|
+
};
|
|
316
|
+
try {
|
|
317
|
+
await writeCache(cacheDir, token.appId, entry);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
// Cache write failure is non-fatal. The SDK still works in memory;
|
|
320
|
+
// the next cold start just can't use the disk fallback.
|
|
321
|
+
console.warn(
|
|
322
|
+
`[anby] failed to write bootstrap cache: ${(err as Error).message}`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 5. Schedule background refresh at 80% of TTL.
|
|
328
|
+
const refreshInMs = Math.max(60_000, discovery.cacheTtlSeconds * 1000 * 0.8);
|
|
329
|
+
scheduleRefresh(opts, refreshInMs);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Background refresh loop. Re-runs bootstrapFromToken at the scheduled
|
|
334
|
+
* interval. On failure, logs and keeps using the existing in-memory
|
|
335
|
+
* config — there's no degraded mode because the data we cache (URLs +
|
|
336
|
+
* public key) is not security-critical and can be stale.
|
|
337
|
+
*/
|
|
338
|
+
function scheduleRefresh(opts: BootstrapOptions, delayMs: number): void {
|
|
339
|
+
const handle = setTimeout(() => {
|
|
340
|
+
bootstrapFromToken(opts).catch((err) => {
|
|
341
|
+
console.warn(
|
|
342
|
+
`[anby] bootstrap refresh failed: ${(err as Error).message} (will retry on next interval)`,
|
|
343
|
+
);
|
|
344
|
+
// Re-schedule with the same delay even on failure.
|
|
345
|
+
scheduleRefresh(opts, delayMs);
|
|
346
|
+
});
|
|
347
|
+
}, delayMs);
|
|
348
|
+
// Don't keep the event loop alive just for refresh.
|
|
349
|
+
if (typeof handle.unref === 'function') handle.unref();
|
|
350
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the third-party app bootstrap flow
|
|
3
|
+
* (PLAN-app-bootstrap.md PR2).
|
|
4
|
+
*
|
|
5
|
+
* `AnbyAppToken` is the parsed payload of the `ANBY_APP_TOKEN` env var.
|
|
6
|
+
* The wire format is `anby_v1_<base64url(json(AnbyAppToken))>`.
|
|
7
|
+
*
|
|
8
|
+
* `DiscoveryResponse` is what `GET /registry/discovery` returns.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const ANBY_TOKEN_PREFIX = 'anby_v1_';
|
|
12
|
+
|
|
13
|
+
export interface AnbyAppToken {
|
|
14
|
+
v: 1;
|
|
15
|
+
appId: string;
|
|
16
|
+
/** Externally-reachable platform base URL, e.g. "https://anby.io". */
|
|
17
|
+
platformUrl: string;
|
|
18
|
+
/** PEM-encoded Ed25519 private key for service-to-service signing. */
|
|
19
|
+
privateKey: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DiscoveryResponse {
|
|
23
|
+
v: 1;
|
|
24
|
+
platform: {
|
|
25
|
+
name: string;
|
|
26
|
+
version: string;
|
|
27
|
+
};
|
|
28
|
+
endpoints: {
|
|
29
|
+
authPublicKeyUrl: string;
|
|
30
|
+
scopedTokenUrl: string;
|
|
31
|
+
entityTokenPublicKeyUrl: string;
|
|
32
|
+
/** PR1 of phase2: events ingestion endpoint. Optional for back-compat
|
|
33
|
+
* with older registries that don't expose it yet. */
|
|
34
|
+
eventsUrl?: string;
|
|
35
|
+
gatewayUrl: string;
|
|
36
|
+
/** Registry HTTP API root with /registry suffix already appended.
|
|
37
|
+
* Existing callers expect this exact shape. */
|
|
38
|
+
registryUrl: string;
|
|
39
|
+
/** PR3 of phase2: registry HOST root WITHOUT /registry suffix.
|
|
40
|
+
* Optional for back-compat with older registries. */
|
|
41
|
+
registryBaseUrl?: string;
|
|
42
|
+
tenantServiceUrl: string;
|
|
43
|
+
eventRouterUrl: string;
|
|
44
|
+
};
|
|
45
|
+
cacheTtlSeconds: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CachedBootstrap {
|
|
49
|
+
/** When the discovery response was fetched (ISO 8601). */
|
|
50
|
+
fetchedAt: string;
|
|
51
|
+
/** When this cache entry should be considered stale (ISO 8601). */
|
|
52
|
+
staleAt: string;
|
|
53
|
+
discovery: DiscoveryResponse;
|
|
54
|
+
/** PEM string of the auth-service public key (RS256). */
|
|
55
|
+
authPublicKeyPem: string;
|
|
56
|
+
}
|