@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
@@ -67,6 +67,16 @@ export interface PublishAppResult {
67
67
  status: string;
68
68
  };
69
69
  version: { appId: string; version: string };
70
+ /**
71
+ * Ed25519 private key (PEM) returned ONCE on first publish. Used to
72
+ * assemble the ANBY_APP_TOKEN connection string for the developer to
73
+ * paste into their app's env. Null on subsequent publishes — the
74
+ * registry never re-returns the key.
75
+ *
76
+ * The CLI uses this to print the assembled token. Programmatic
77
+ * callers should treat this field as a secret and avoid logging it.
78
+ */
79
+ privateKey: string | null;
70
80
  }
71
81
 
72
82
  async function loadManifest(path: string): Promise<AppManifest> {
@@ -81,6 +91,25 @@ async function loadManifest(path: string): Promise<AppManifest> {
81
91
  return parsed;
82
92
  }
83
93
 
94
+ /**
95
+ * Public helper: load the manifest from disk and return it with all
96
+ * `provides.entities[].schema` paths resolved to inlined JSON content.
97
+ *
98
+ * Used both internally by `publishAppFromManifest` and by services that
99
+ * want to expose the wire-format manifest over HTTP (e.g. a Remix route
100
+ * at `/_anby/manifest`) so the Marketplace submit form can fetch it
101
+ * without requiring the publisher to paste anything by hand.
102
+ */
103
+ export async function getInlinedManifest(
104
+ opts: { manifestPath?: string } = {},
105
+ ): Promise<AppManifest> {
106
+ const manifestPath = resolve(
107
+ opts.manifestPath ?? join(process.cwd(), 'anby-app.manifest.json'),
108
+ );
109
+ const raw = await loadManifest(manifestPath);
110
+ return inlineEntitySchemas(raw, manifestPath);
111
+ }
112
+
84
113
  /**
85
114
  * Entity schema inliner (CR-4).
86
115
  *
@@ -163,20 +192,30 @@ export async function publishAppFromManifest(
163
192
  const manifestPath = resolve(
164
193
  opts.manifestPath ?? join(process.cwd(), 'anby-app.manifest.json'),
165
194
  );
166
- const rawManifest = await loadManifest(manifestPath);
167
195
  // CR-4: inline schema content before sending. Manifest on disk uses
168
196
  // relative paths for DX; wire format sends the actual JSON object.
169
- const manifest = await inlineEntitySchemas(rawManifest, manifestPath);
197
+ const manifest = await getInlinedManifest({ manifestPath });
170
198
 
171
199
  const publicUrl =
172
200
  opts.publicUrl ??
173
201
  process.env.APP_PUBLIC_URL ??
174
202
  `http://localhost:${manifest.runtime.port}`;
175
203
 
176
- const registryUrl =
177
- opts.registryUrl ??
178
- process.env.REGISTRY_URL ??
179
- 'http://localhost:3003';
204
+ // PLAN-app-bootstrap-phase2 PR4: prefer the registry URL discovered
205
+ // by bootstrapFromToken. Falls back to the explicit option, then env,
206
+ // then localhost. The discovered value is the registry HOST root
207
+ // (no /registry suffix), so this code can append /registry/apps below.
208
+ let registryUrl = opts.registryUrl ?? process.env.REGISTRY_URL;
209
+ if (!registryUrl) {
210
+ try {
211
+ // Lazy require to avoid a circular import at module load time.
212
+ const { getDiscoveredRegistryBaseUrl } = await import('../bootstrap/index.js');
213
+ registryUrl = await getDiscoveredRegistryBaseUrl();
214
+ } catch {
215
+ // bootstrap not started yet — fall back to localhost dev default.
216
+ registryUrl = 'http://localhost:3003';
217
+ }
218
+ }
180
219
 
181
220
  const internalApiSecret =
182
221
  opts.internalApiSecret ?? process.env.INTERNAL_API_SECRET ?? '';
@@ -0,0 +1,249 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { generateKeyPairSync } from 'node:crypto';
3
+ import jwt from 'jsonwebtoken';
4
+ import {
5
+ configureAuth,
6
+ verifyUserJwt,
7
+ verifyInternalJwt,
8
+ verifyHmac,
9
+ signHmac,
10
+ authenticateRequest,
11
+ JWT_ISSUER,
12
+ JWT_AUDIENCE,
13
+ TYP_USER,
14
+ TYP_INTERNAL,
15
+ TYP_OAUTH_STATE,
16
+ } from './index.js';
17
+
18
+ // Generate a single test keypair shared across all tests in this file.
19
+ // ~50ms one-time cost.
20
+ const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
21
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
22
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
23
+
24
+ const HMAC_SECRET = 'test-hmac-secret';
25
+
26
+ function signRS256User(claims: Record<string, unknown> = {}): string {
27
+ return jwt.sign(
28
+ { sub: 'user-1', email: 'u@bravebits.vn', tenantId: 't1', typ: TYP_USER, ...claims },
29
+ privateKeyPem,
30
+ { algorithm: 'RS256', issuer: JWT_ISSUER, audience: JWT_AUDIENCE, expiresIn: '1h' },
31
+ );
32
+ }
33
+
34
+ function signRS256Internal(): string {
35
+ return jwt.sign(
36
+ { sub: 'user-1', email: 'u@bravebits.vn', typ: TYP_INTERNAL },
37
+ privateKeyPem,
38
+ { algorithm: 'RS256', issuer: JWT_ISSUER, audience: JWT_AUDIENCE, expiresIn: '1h' },
39
+ );
40
+ }
41
+
42
+ function signRS256OAuthState(): string {
43
+ return jwt.sign(
44
+ { returnUrl: '/dashboard', nonce: 'test-nonce', typ: TYP_OAUTH_STATE },
45
+ privateKeyPem,
46
+ { algorithm: 'RS256', issuer: JWT_ISSUER, audience: JWT_AUDIENCE, expiresIn: '5m' },
47
+ );
48
+ }
49
+
50
+ describe('configureAuth', () => {
51
+ it('rejects empty config', () => {
52
+ expect(() => configureAuth({})).toThrow();
53
+ });
54
+
55
+ it('accepts hmacSecret only (registry use case)', () => {
56
+ configureAuth({ hmacSecret: HMAC_SECRET });
57
+ const sig = signHmac('alice@bravebits.vn');
58
+ expect(verifyHmac('alice@bravebits.vn', sig)).toBe(true);
59
+ });
60
+
61
+ it('accepts jwtPublicKey only (read-only consumer use case)', () => {
62
+ configureAuth({ jwtPublicKey: publicKeyPem });
63
+ expect(() => verifyUserJwt(signRS256User())).not.toThrow();
64
+ });
65
+ });
66
+
67
+ describe('verifyUserJwt — RS256 path', () => {
68
+ beforeEach(() => {
69
+ configureAuth({
70
+ jwtPublicKey: publicKeyPem,
71
+ hmacSecret: HMAC_SECRET,
72
+ });
73
+ });
74
+
75
+ it('accepts a valid RS256 user token', () => {
76
+ const user = verifyUserJwt(signRS256User());
77
+ expect(user.id).toBe('user-1');
78
+ expect(user.email).toBe('u@bravebits.vn');
79
+ expect(user.tenantId).toBe('t1');
80
+ });
81
+
82
+ it('rejects an expired token', () => {
83
+ const expired = jwt.sign(
84
+ { sub: 'u', email: 'u@x.com', typ: TYP_USER },
85
+ privateKeyPem,
86
+ { algorithm: 'RS256', issuer: JWT_ISSUER, audience: JWT_AUDIENCE, expiresIn: '-1h' },
87
+ );
88
+ expect(() => verifyUserJwt(expired)).toThrow();
89
+ });
90
+
91
+ it('rejects a token signed with a different key', () => {
92
+ const { privateKey: otherKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
93
+ const evil = jwt.sign(
94
+ { sub: 'u', email: 'u@x.com', typ: TYP_USER },
95
+ otherKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
96
+ { algorithm: 'RS256', issuer: JWT_ISSUER, audience: JWT_AUDIENCE, expiresIn: '1h' },
97
+ );
98
+ expect(() => verifyUserJwt(evil)).toThrow();
99
+ });
100
+
101
+ it('rejects a token with wrong issuer', () => {
102
+ const evil = jwt.sign(
103
+ { sub: 'u', email: 'u@x.com', typ: TYP_USER },
104
+ privateKeyPem,
105
+ { algorithm: 'RS256', issuer: 'evil', audience: JWT_AUDIENCE, expiresIn: '1h' },
106
+ );
107
+ expect(() => verifyUserJwt(evil)).toThrow();
108
+ });
109
+
110
+ it('rejects a token with wrong audience', () => {
111
+ const evil = jwt.sign(
112
+ { sub: 'u', email: 'u@x.com', typ: TYP_USER },
113
+ privateKeyPem,
114
+ { algorithm: 'RS256', issuer: JWT_ISSUER, audience: 'evil', expiresIn: '1h' },
115
+ );
116
+ expect(() => verifyUserJwt(evil)).toThrow();
117
+ });
118
+ });
119
+
120
+ describe('CRITICAL: cross-token confusion', () => {
121
+ beforeEach(() => {
122
+ configureAuth({
123
+ jwtPublicKey: publicKeyPem,
124
+ hmacSecret: HMAC_SECRET,
125
+ });
126
+ });
127
+
128
+ it('verifyUserJwt MUST reject an internal JWT', () => {
129
+ expect(() => verifyUserJwt(signRS256Internal())).toThrow();
130
+ });
131
+
132
+ it('verifyUserJwt MUST reject an OAuth state token', () => {
133
+ expect(() => verifyUserJwt(signRS256OAuthState())).toThrow();
134
+ });
135
+
136
+ it('verifyInternalJwt MUST reject a user JWT', () => {
137
+ expect(() => verifyInternalJwt(signRS256User())).toThrow();
138
+ });
139
+
140
+ it('verifyInternalJwt MUST reject an OAuth state token', () => {
141
+ expect(() => verifyInternalJwt(signRS256OAuthState())).toThrow();
142
+ });
143
+
144
+ });
145
+
146
+ describe('CRITICAL: algorithm confusion', () => {
147
+ beforeEach(() => {
148
+ configureAuth({
149
+ jwtPublicKey: publicKeyPem,
150
+ hmacSecret: HMAC_SECRET,
151
+ });
152
+ });
153
+
154
+ it('rejects alg:none tokens', () => {
155
+ const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
156
+ const payload = Buffer.from(
157
+ JSON.stringify({
158
+ sub: 'attacker',
159
+ email: 'a@evil.com',
160
+ typ: TYP_USER,
161
+ iss: JWT_ISSUER,
162
+ aud: JWT_AUDIENCE,
163
+ exp: Math.floor(Date.now() / 1000) + 3600,
164
+ }),
165
+ ).toString('base64url');
166
+ expect(() => verifyUserJwt(`${header}.${payload}.`)).toThrow();
167
+ });
168
+
169
+ it('rejects HS256 tokens signed with the public key as the secret (alg confusion)', () => {
170
+ const evil = jwt.sign(
171
+ { sub: 'attacker', email: 'a@evil.com', typ: TYP_USER },
172
+ publicKeyPem,
173
+ { algorithm: 'HS256', expiresIn: '1h' },
174
+ );
175
+ expect(() => verifyUserJwt(evil)).toThrow();
176
+ });
177
+ });
178
+
179
+ // PR4: dual-verify and HS256-only mode tests removed alongside the fallback paths.
180
+ // SDK is RS256-only post-PR4. The verifyUserJwt — RS256 path tests above cover
181
+ // the canonical happy path and edge cases.
182
+
183
+ describe('HMAC service-to-service (regression)', () => {
184
+ beforeEach(() => {
185
+ configureAuth({
186
+ jwtPublicKey: publicKeyPem,
187
+ hmacSecret: HMAC_SECRET,
188
+ });
189
+ });
190
+
191
+ it('signHmac and verifyHmac round-trip', () => {
192
+ const sig = signHmac('alice@bravebits.vn');
193
+ expect(verifyHmac('alice@bravebits.vn', sig)).toBe(true);
194
+ });
195
+
196
+ it('verifyHmac rejects tampered user value', () => {
197
+ const sig = signHmac('alice@bravebits.vn');
198
+ expect(verifyHmac('bob@bravebits.vn', sig)).toBe(false);
199
+ });
200
+
201
+ it('verifyHmac rejects wrong-length signature without throwing', () => {
202
+ expect(verifyHmac('alice@bravebits.vn', 'short')).toBe(false);
203
+ });
204
+ });
205
+
206
+ describe('authenticateRequest', () => {
207
+ beforeEach(() => {
208
+ configureAuth({
209
+ jwtPublicKey: publicKeyPem,
210
+ hmacSecret: HMAC_SECRET,
211
+ });
212
+ });
213
+
214
+ function makeRequest(headers: Record<string, string>): Request {
215
+ return new Request('http://localhost/test', { headers });
216
+ }
217
+
218
+ it('authenticates RS256 Bearer token', () => {
219
+ const req = makeRequest({ authorization: `Bearer ${signRS256User()}` });
220
+ const user = authenticateRequest(req);
221
+ expect(user?.id).toBe('user-1');
222
+ });
223
+
224
+ it('authenticates RS256 cookie token (Remix SSR path)', () => {
225
+ const req = makeRequest({ cookie: `auth-token=${signRS256User()}` });
226
+ const user = authenticateRequest(req);
227
+ expect(user?.id).toBe('user-1');
228
+ });
229
+
230
+ it('rejects internal JWT presented as Bearer (cross-token confusion)', () => {
231
+ const req = makeRequest({ authorization: `Bearer ${signRS256Internal()}` });
232
+ expect(authenticateRequest(req)).toBeNull();
233
+ });
234
+
235
+ it('authenticates HMAC service-to-service via x-internal-* headers', () => {
236
+ const sig = signHmac('worker@svc');
237
+ const req = makeRequest({
238
+ 'x-internal-user': 'worker@svc',
239
+ 'x-internal-signature': sig,
240
+ });
241
+ const user = authenticateRequest(req);
242
+ expect(user?.id).toBe('internal');
243
+ expect(user?.email).toBe('worker@svc');
244
+ });
245
+
246
+ it('returns null for unauthenticated request', () => {
247
+ expect(authenticateRequest(makeRequest({}))).toBeNull();
248
+ });
249
+ });
package/src/auth/index.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  import jwt from 'jsonwebtoken';
2
2
  import crypto from 'crypto';
3
3
 
4
+ // JWT claim conventions for the asymmetric (RS256) auth path. Must match
5
+ // anby-auth-service/src/auth/auth.service.ts. All RS256 tokens carry these
6
+ // iss/aud/typ claims; all RS256 verifiers enforce them.
7
+ //
8
+ // PR4 cleanup: HS256 fallback paths removed. SDK is RS256-only.
9
+ export const JWT_ISSUER = 'anby-auth-service';
10
+ export const JWT_AUDIENCE = 'anby-platform';
11
+ export const TYP_USER = 'user';
12
+ export const TYP_INTERNAL = 'internal';
13
+ export const TYP_OAUTH_STATE = 'oauth-state';
14
+
4
15
  export interface AuthUser {
5
16
  id: string;
6
17
  email: string;
@@ -8,30 +19,51 @@ export interface AuthUser {
8
19
  tenantId: string;
9
20
  }
10
21
 
22
+ /**
23
+ * Auth SDK config. Pass via configureAuth() before any verify* call.
24
+ *
25
+ * After PR4 cleanup, only the RS256 + HMAC fields exist:
26
+ * - jwtPublicKey: REQUIRED for any user/internal token verification
27
+ * - hmacSecret: REQUIRED for service-to-service HMAC (registry use case)
28
+ *
29
+ * For services that only verify JWTs and never use HMAC, pass only jwtPublicKey.
30
+ * For services that only use HMAC (e.g. app-registry), pass only hmacSecret.
31
+ */
11
32
  export interface AuthConfig {
12
- jwtSecret: string;
13
- internalApiSecret: string;
33
+ jwtPublicKey?: string; // RSA-2048 PEM
34
+ hmacSecret?: string; // service-to-service HMAC secret
35
+ }
36
+
37
+ interface InternalConfig {
38
+ jwtPublicKey: string | null;
39
+ hmacSecret: string | null;
14
40
  }
15
41
 
16
- let _config: AuthConfig | null = null;
42
+ let _config: InternalConfig | null = null;
17
43
 
44
+ /**
45
+ * Configure the SDK's auth state. Call once at service boot, before any
46
+ * verify* call.
47
+ */
18
48
  export function configureAuth(config: AuthConfig): void {
19
- _config = config;
49
+ const jwtPublicKey = config.jwtPublicKey ?? null;
50
+ const hmacSecret = config.hmacSecret ?? null;
51
+
52
+ if (!jwtPublicKey && !hmacSecret) {
53
+ throw new Error(
54
+ 'configureAuth: at least one of jwtPublicKey or hmacSecret must be set',
55
+ );
56
+ }
57
+
58
+ _config = { jwtPublicKey, hmacSecret };
20
59
  }
21
60
 
22
- function getConfig(): AuthConfig {
61
+ function getConfig(): InternalConfig {
23
62
  if (!_config) throw new Error('Auth not configured. Call configureAuth() first.');
24
63
  return _config;
25
64
  }
26
65
 
27
- export function verifyJwt(token: string): AuthUser {
28
- const config = getConfig();
29
- const payload = jwt.verify(token, config.jwtSecret) as {
30
- sub: string;
31
- email: string;
32
- name?: string;
33
- tenantId?: string;
34
- };
66
+ function payloadToAuthUser(payload: any): AuthUser {
35
67
  return {
36
68
  id: payload.sub,
37
69
  email: payload.email,
@@ -40,13 +72,69 @@ export function verifyJwt(token: string): AuthUser {
40
72
  };
41
73
  }
42
74
 
75
+ /**
76
+ * Verify a user session JWT (RS256). CRITICAL (cross-token confusion fix):
77
+ * rejects tokens with typ != "user". An internal-token (typ:internal) or
78
+ * oauth-state token (typ:oauth-state) presented as a user session is rejected
79
+ * even if the signature, iss, aud, and exp would all validate.
80
+ */
81
+ export function verifyUserJwt(token: string): AuthUser {
82
+ const config = getConfig();
83
+ if (!config.jwtPublicKey) {
84
+ throw new Error('jwtPublicKey not configured');
85
+ }
86
+ if (!token || token.split('.').length !== 3) {
87
+ throw new Error('Invalid token format');
88
+ }
89
+ const payload = jwt.verify(token, config.jwtPublicKey, {
90
+ algorithms: ['RS256'],
91
+ issuer: JWT_ISSUER,
92
+ audience: JWT_AUDIENCE,
93
+ clockTolerance: 30,
94
+ }) as any;
95
+ if (payload.typ !== TYP_USER) {
96
+ throw new Error('Wrong token class for user verifier');
97
+ }
98
+ return payloadToAuthUser(payload);
99
+ }
100
+
101
+ /**
102
+ * Verify an internal-service JWT (RS256). CRITICAL: rejects tokens with
103
+ * typ != "internal".
104
+ */
105
+ export function verifyInternalJwt(token: string): AuthUser {
106
+ const config = getConfig();
107
+ if (!config.jwtPublicKey) {
108
+ throw new Error('jwtPublicKey not configured');
109
+ }
110
+ if (!token || token.split('.').length !== 3) {
111
+ throw new Error('Invalid token format');
112
+ }
113
+ const payload = jwt.verify(token, config.jwtPublicKey, {
114
+ algorithms: ['RS256'],
115
+ issuer: JWT_ISSUER,
116
+ audience: JWT_AUDIENCE,
117
+ clockTolerance: 30,
118
+ }) as any;
119
+ if (payload.typ !== TYP_INTERNAL) {
120
+ throw new Error('Wrong token class for internal verifier');
121
+ }
122
+ return payloadToAuthUser(payload);
123
+ }
124
+
43
125
  export function verifyHmac(userValue: string, signature: string): boolean {
44
126
  const config = getConfig();
127
+ if (!config.hmacSecret) {
128
+ throw new Error('HMAC not configured. Set hmacSecret in configureAuth().');
129
+ }
45
130
  const expected = crypto
46
- .createHmac('sha256', config.internalApiSecret)
131
+ .createHmac('sha256', config.hmacSecret)
47
132
  .update(userValue)
48
133
  .digest('hex');
49
- return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
134
+ const expectedBuf = Buffer.from(expected);
135
+ const actualBuf = Buffer.from(signature);
136
+ if (expectedBuf.length !== actualBuf.length) return false;
137
+ return crypto.timingSafeEqual(expectedBuf, actualBuf);
50
138
  }
51
139
 
52
140
  /**
@@ -55,48 +143,54 @@ export function verifyHmac(userValue: string, signature: string): boolean {
55
143
  * the receiving side calls `verifyHmac` with the same pair.
56
144
  */
57
145
  export function signHmac(userValue: string, secret?: string): string {
58
- const internalApiSecret = secret ?? getConfig().internalApiSecret;
59
- return crypto
60
- .createHmac('sha256', internalApiSecret)
61
- .update(userValue)
62
- .digest('hex');
146
+ const hmacSecret = secret ?? getConfig().hmacSecret;
147
+ if (!hmacSecret) {
148
+ throw new Error('HMAC not configured. Set hmacSecret in configureAuth().');
149
+ }
150
+ return crypto.createHmac('sha256', hmacSecret).update(userValue).digest('hex');
63
151
  }
64
152
 
65
153
  export function authenticateRequest(request: Request): AuthUser | null {
66
- // Try JWT from Authorization header
154
+ // Try JWT from Authorization header (user session)
67
155
  const authHeader = request.headers.get('authorization');
68
156
  if (authHeader?.startsWith('Bearer ')) {
69
157
  try {
70
- return verifyJwt(authHeader.slice(7));
158
+ return verifyUserJwt(authHeader.slice(7));
71
159
  } catch {
72
160
  return null;
73
161
  }
74
162
  }
75
163
 
76
- // Try JWT from cookie
164
+ // Try JWT from cookie (Remix/SSR path)
77
165
  const cookies = request.headers.get('cookie') || '';
78
166
  const authCookie = cookies.split(';').find(c => c.trim().startsWith('auth-token='));
79
167
  if (authCookie) {
80
168
  const token = authCookie.split('=')[1]?.trim();
81
169
  if (token) {
82
170
  try {
83
- return verifyJwt(token);
171
+ return verifyUserJwt(token);
84
172
  } catch {
85
173
  return null;
86
174
  }
87
175
  }
88
176
  }
89
177
 
90
- // Try HMAC (service-to-service)
178
+ // Try HMAC (service-to-service). Only if hmacSecret is configured.
91
179
  const internalUser = request.headers.get('x-internal-user');
92
180
  const internalSig = request.headers.get('x-internal-signature');
93
- if (internalUser && internalSig && verifyHmac(internalUser, internalSig)) {
94
- return {
95
- id: 'internal',
96
- email: internalUser,
97
- name: 'Internal Service',
98
- tenantId: request.headers.get('x-tenant-id') || 'default',
99
- };
181
+ if (internalUser && internalSig) {
182
+ try {
183
+ if (verifyHmac(internalUser, internalSig)) {
184
+ return {
185
+ id: 'internal',
186
+ email: internalUser,
187
+ name: 'Internal Service',
188
+ tenantId: request.headers.get('x-tenant-id') || 'default',
189
+ };
190
+ }
191
+ } catch {
192
+ // hmacSecret not configured — fall through
193
+ }
100
194
  }
101
195
 
102
196
  return null;
@@ -0,0 +1,60 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import type { CachedBootstrap } from './types.js';
4
+
5
+ /**
6
+ * Disk cache for the bootstrap discovery response. Used to make cold
7
+ * starts resilient when the platform discovery endpoint is briefly
8
+ * unreachable.
9
+ *
10
+ * Cache is per-app (keyed by appId so multiple apps in the same workdir
11
+ * don't collide). Atomic write via temp file + rename. File mode 0600 so
12
+ * the cache file is owner-only on POSIX systems.
13
+ *
14
+ * Format: JSON. Contains the discovery response and the auth public key
15
+ * PEM. Does NOT contain the per-app private key — that comes from the
16
+ * connection-string token at every boot.
17
+ */
18
+
19
+ function cacheFilePath(cacheDir: string, appId: string): string {
20
+ // Sanitize appId for filename safety: keep alphanumerics + dashes + dots,
21
+ // replace everything else with underscore.
22
+ const safeId = appId.replace(/[^a-zA-Z0-9.\-_]/g, '_');
23
+ return join(cacheDir, `bootstrap-${safeId}.json`);
24
+ }
25
+
26
+ export async function readCache(
27
+ cacheDir: string,
28
+ appId: string,
29
+ ): Promise<CachedBootstrap | null> {
30
+ try {
31
+ const path = cacheFilePath(cacheDir, appId);
32
+ const raw = await fs.readFile(path, 'utf-8');
33
+ const parsed = JSON.parse(raw) as CachedBootstrap;
34
+ if (
35
+ typeof parsed?.fetchedAt !== 'string' ||
36
+ typeof parsed?.staleAt !== 'string' ||
37
+ !parsed?.discovery?.endpoints ||
38
+ typeof parsed?.authPublicKeyPem !== 'string'
39
+ ) {
40
+ return null;
41
+ }
42
+ return parsed;
43
+ } catch {
44
+ // ENOENT, parse error, anything — fall back to fresh fetch
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export async function writeCache(
50
+ cacheDir: string,
51
+ appId: string,
52
+ entry: CachedBootstrap,
53
+ ): Promise<void> {
54
+ const path = cacheFilePath(cacheDir, appId);
55
+ await fs.mkdir(dirname(path), { recursive: true });
56
+ // Atomic write: stage to temp, rename. Rename is atomic on POSIX.
57
+ const tmp = `${path}.tmp.${process.pid}`;
58
+ await fs.writeFile(tmp, JSON.stringify(entry, null, 2), { mode: 0o600 });
59
+ await fs.rename(tmp, path);
60
+ }