@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.
Files changed (67) hide show
  1. package/dist/cjs/apps/publish.d.ts +23 -0
  2. package/dist/cjs/apps/publish.d.ts.map +1 -1
  3. package/dist/cjs/apps/publish.js +65 -5
  4. package/dist/cjs/apps/publish.js.map +1 -1
  5. package/dist/cjs/auth/index.d.ts +33 -3
  6. package/dist/cjs/auth/index.d.ts.map +1 -1
  7. package/dist/cjs/auth/index.js +105 -24
  8. package/dist/cjs/auth/index.js.map +1 -1
  9. package/dist/cjs/bootstrap/cache.d.ts +4 -0
  10. package/dist/cjs/bootstrap/cache.d.ts.map +1 -0
  11. package/dist/cjs/bootstrap/cache.js +52 -0
  12. package/dist/cjs/bootstrap/cache.js.map +1 -0
  13. package/dist/cjs/bootstrap/index.d.ts +79 -0
  14. package/dist/cjs/bootstrap/index.d.ts.map +1 -0
  15. package/dist/cjs/bootstrap/index.js +280 -0
  16. package/dist/cjs/bootstrap/index.js.map +1 -0
  17. package/dist/cjs/bootstrap/types.d.ts +53 -0
  18. package/dist/cjs/bootstrap/types.d.ts.map +1 -0
  19. package/dist/cjs/bootstrap/types.js +14 -0
  20. package/dist/cjs/bootstrap/types.js.map +1 -0
  21. package/dist/cjs/events/http-transport.d.ts +38 -0
  22. package/dist/cjs/events/http-transport.d.ts.map +1 -0
  23. package/dist/cjs/events/http-transport.js +63 -0
  24. package/dist/cjs/events/http-transport.js.map +1 -0
  25. package/dist/cjs/events/index.d.ts +49 -0
  26. package/dist/cjs/events/index.d.ts.map +1 -1
  27. package/dist/cjs/events/index.js +14 -1
  28. package/dist/cjs/events/index.js.map +1 -1
  29. package/dist/cjs/index.d.ts +6 -3
  30. package/dist/cjs/index.d.ts.map +1 -1
  31. package/dist/cjs/index.js +30 -11
  32. package/dist/cjs/index.js.map +1 -1
  33. package/dist/cjs/vite/index.d.ts +20 -0
  34. package/dist/cjs/vite/index.d.ts.map +1 -0
  35. package/dist/cjs/vite/index.js +154 -0
  36. package/dist/cjs/vite/index.js.map +1 -0
  37. package/dist/esm/apps/publish.js +31 -5
  38. package/dist/esm/apps/publish.js.map +1 -1
  39. package/dist/esm/auth/index.js +102 -23
  40. package/dist/esm/auth/index.js.map +1 -1
  41. package/dist/esm/bootstrap/cache.js +48 -0
  42. package/dist/esm/bootstrap/cache.js.map +1 -0
  43. package/dist/esm/bootstrap/index.js +272 -0
  44. package/dist/esm/bootstrap/index.js.map +1 -0
  45. package/dist/esm/bootstrap/types.js +11 -0
  46. package/dist/esm/bootstrap/types.js.map +1 -0
  47. package/dist/esm/events/http-transport.js +59 -0
  48. package/dist/esm/events/http-transport.js.map +1 -0
  49. package/dist/esm/events/index.js +14 -1
  50. package/dist/esm/events/index.js.map +1 -1
  51. package/dist/esm/index.js +8 -2
  52. package/dist/esm/index.js.map +1 -1
  53. package/dist/esm/vite/index.js +151 -0
  54. package/dist/esm/vite/index.js.map +1 -0
  55. package/package.json +14 -1
  56. package/src/apps/publish.ts +45 -6
  57. package/src/auth/index.test.ts +249 -0
  58. package/src/auth/index.ts +126 -32
  59. package/src/bootstrap/cache.ts +60 -0
  60. package/src/bootstrap/index.test.ts +277 -0
  61. package/src/bootstrap/index.ts +350 -0
  62. package/src/bootstrap/types.ts +56 -0
  63. package/src/events/http-transport.test.ts +135 -0
  64. package/src/events/http-transport.ts +77 -0
  65. package/src/events/index.ts +73 -2
  66. package/src/index.ts +29 -1
  67. 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
+ }