@oimlsmart/platform-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +92 -0
  2. package/migrations/0001_init.sql +69 -0
  3. package/migrations/0002_identity.sql +24 -0
  4. package/migrations/0003_federation_peers.sql +21 -0
  5. package/migrations/0003_users_rbac.sql +8 -0
  6. package/migrations/0004_oidc_op.sql +61 -0
  7. package/migrations/0005_upstream_providers.sql +34 -0
  8. package/migrations/0006_op_accounts.sql +28 -0
  9. package/migrations/0007_org_join_requests.sql +26 -0
  10. package/migrations/0008_op_client_roles.sql +19 -0
  11. package/migrations/0009_account_console.sql +32 -0
  12. package/migrations/0009_sso_states.sql +13 -0
  13. package/migrations/0010_notify_events.sql +23 -0
  14. package/migrations/0011_op_launch.sql +18 -0
  15. package/migrations/0011_org_memberships.sql +62 -0
  16. package/migrations/0012_notify_subscriptions.sql +57 -0
  17. package/migrations/0012_strong_auth.sql +109 -0
  18. package/migrations/0013_org_registry.sql +53 -0
  19. package/migrations/0014_notify_inbox.sql +34 -0
  20. package/migrations/0015_certificate_holder_attribution.sql +63 -0
  21. package/migrations/0016_instrument_registrations.sql +78 -0
  22. package/package.json +52 -0
  23. package/src/client-info.ts +25 -0
  24. package/src/context.ts +31 -0
  25. package/src/github.ts +284 -0
  26. package/src/mailer.ts +309 -0
  27. package/src/oidc.ts +369 -0
  28. package/src/profile/node.ts +83 -0
  29. package/src/profile.ts +582 -0
  30. package/src/rbac/node.ts +42 -0
  31. package/src/rbac.ts +53 -0
  32. package/src/session.ts +45 -0
  33. package/src/store/d1.ts +2850 -0
  34. package/src/store/sqlite/entities.ts +71 -0
  35. package/src/store/sqlite/events.ts +82 -0
  36. package/src/store/sqlite/factors-store.ts +348 -0
  37. package/src/store/sqlite/notify.ts +247 -0
  38. package/src/store/sqlite/op-accounts-store.ts +470 -0
  39. package/src/store/sqlite/op-store.ts +280 -0
  40. package/src/store/sqlite/schema.sql +745 -0
  41. package/src/store/sqlite/store.ts +1390 -0
  42. package/src/store/sqlite/upstream-store.ts +148 -0
  43. package/src/store/sqlite.ts +1027 -0
  44. package/src/store.ts +1826 -0
  45. package/src/vocab/index.ts +12 -0
  46. package/src/vocab/permissions.ts +398 -0
  47. package/src/vocab/rbac.ts +281 -0
  48. package/src/vocab/roles.ts +162 -0
package/src/oidc.ts ADDED
@@ -0,0 +1,369 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The OIDC client (TODO.federation/10) — relying-party half of the
3
+ // Authorization Code + PKCE flow, hand-rolled on WebCrypto + fetch.
4
+ //
5
+ // NO LIBRARY DEPENDENCY, deliberately: every runtime this server runs
6
+ // on (node ≥ 18 via @hono/node-server, Cloudflare Workers) ships
7
+ // WebCrypto and fetch, and the platform already hand-rolls its GitHub
8
+ // OAuth flow the same way (routes/auth.ts). A general OIDC library
9
+ // (openid-client et al.) would add a node-centric dependency tree for
10
+ // ~300 lines of standard-conformant HTTP+JWT work we can test directly.
11
+ //
12
+ // What this module implements (OIDC Core 1.0):
13
+ // - issuer discovery (GET <issuer>/.well-known/openid-configuration,
14
+ // RFC 8414 location; the metadata's issuer MUST match exactly);
15
+ // - the authorization-request URL with PKCE (S256) + state + nonce;
16
+ // - the code exchange at the token endpoint (client_secret_basic when
17
+ // a secret is configured, public-client body auth otherwise);
18
+ // - ID-token validation: signature against the IdP's JWKS (RS256 and
19
+ // ES256), iss, aud (+ azp when multiple audiences), exp (60 s
20
+ // leeway), and the nonce we issued;
21
+ // - RP-initiated logout URL (OIDC RP-Initiated Logout 1.0) when the
22
+ // metadata declares end_session_endpoint.
23
+ //
24
+ // Failures raise OidcError with a machine `reason` — routes/auth.ts
25
+ // maps reasons to the plain-language sign-in error page, never a stack
26
+ // trace.
27
+ //
28
+ // WORKER-SAFE: WebCrypto + fetch only, no node built-ins.
29
+ // ═══════════════════════════════════════════════════════════════════
30
+
31
+ // ── failure surface ─────────────────────────────────────────────────
32
+
33
+ export type OidcFailureReason =
34
+ | 'discovery' // the issuer's metadata could not be fetched/parsed
35
+ | 'issuer_mismatch' // metadata.issuer ≠ the configured issuer
36
+ | 'exchange' // the token endpoint refused the code exchange
37
+ | 'token_malformed' // the ID token is not a JWT we can parse
38
+ | 'token_alg' // the ID token uses an algorithm we do not verify
39
+ | 'token_signature' // signature verification failed / no matching key
40
+ | 'token_issuer' // iss ≠ the configured issuer
41
+ | 'token_audience' // aud/azp does not name our client
42
+ | 'token_expired' // exp is in the past (60 s leeway allowed)
43
+ | 'token_nonce' // nonce ≠ the one we issued (replay guard)
44
+
45
+ export class OidcError extends Error {
46
+ constructor(
47
+ readonly reason: OidcFailureReason,
48
+ message: string,
49
+ ) {
50
+ super(message)
51
+ this.name = 'OidcError'
52
+ }
53
+ }
54
+
55
+ // ── discovery ───────────────────────────────────────────────────────
56
+
57
+ export interface OidcMetadata {
58
+ issuer: string
59
+ authorization_endpoint: string
60
+ token_endpoint: string
61
+ jwks_uri: string
62
+ /** RP-initiated logout — ABSENT when the IdP does not support it. */
63
+ end_session_endpoint?: string
64
+ userinfo_endpoint?: string
65
+ }
66
+
67
+ interface CachedMetadata { metadata: OidcMetadata; fetchedAt: number }
68
+ const metadataCache = new Map<string, CachedMetadata>()
69
+ const METADATA_TTL_MS = 60 * 60 * 1000
70
+
71
+ /** Discover (and cache) the issuer's metadata. The metadata's issuer
72
+ * MUST equal the configured issuer string exactly (mix-up guard). */
73
+ export async function discoverIssuer(issuer: string, fetchImpl: typeof fetch = fetch): Promise<OidcMetadata> {
74
+ const cached = metadataCache.get(issuer)
75
+ if (cached && Date.now() - cached.fetchedAt < METADATA_TTL_MS) return cached.metadata
76
+
77
+ const wellKnown = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`
78
+ let body: unknown
79
+ try {
80
+ const res = await fetchImpl(wellKnown)
81
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
82
+ body = await res.json()
83
+ } catch (err) {
84
+ throw new OidcError('discovery', `could not fetch ${wellKnown}: ${(err as Error).message}`)
85
+ }
86
+ const meta = body as Partial<OidcMetadata>
87
+ if (typeof meta?.issuer !== 'string' || typeof meta?.authorization_endpoint !== 'string'
88
+ || typeof meta?.token_endpoint !== 'string' || typeof meta?.jwks_uri !== 'string') {
89
+ throw new OidcError('discovery', `the metadata at ${wellKnown} is incomplete (issuer/authorization_endpoint/token_endpoint/jwks_uri required)`)
90
+ }
91
+ if (meta.issuer.replace(/\/$/, '') !== issuer.replace(/\/$/, '')) {
92
+ throw new OidcError('issuer_mismatch', `the metadata declares issuer ${meta.issuer}, not ${issuer}`)
93
+ }
94
+ const metadata: OidcMetadata = {
95
+ issuer: meta.issuer,
96
+ authorization_endpoint: meta.authorization_endpoint,
97
+ token_endpoint: meta.token_endpoint,
98
+ jwks_uri: meta.jwks_uri,
99
+ ...(typeof meta.end_session_endpoint === 'string' ? { end_session_endpoint: meta.end_session_endpoint } : {}),
100
+ ...(typeof meta.userinfo_endpoint === 'string' ? { userinfo_endpoint: meta.userinfo_endpoint } : {}),
101
+ }
102
+ metadataCache.set(issuer, { metadata, fetchedAt: Date.now() })
103
+ return metadata
104
+ }
105
+
106
+ /** Test hook: drop the cached metadata + JWKS (the e2e stub rotates). */
107
+ export function clearOidcCaches(): void {
108
+ metadataCache.clear()
109
+ jwksCache.clear()
110
+ }
111
+
112
+ // ── PKCE + one-time values ──────────────────────────────────────────
113
+
114
+ function base64url(bytes: Uint8Array): string {
115
+ let bin = ''
116
+ for (const b of bytes) bin += String.fromCharCode(b)
117
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
118
+ }
119
+
120
+ function base64urlDecode(s: string): Uint8Array {
121
+ const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice(0, (4 - (s.length % 4)) % 4)
122
+ const bin = atob(b64)
123
+ const out = new Uint8Array(bin.length)
124
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
125
+ return out
126
+ }
127
+
128
+ /** A one-time random value (state / nonce / PKCE verifier alphabet). */
129
+ export function randomToken(): string {
130
+ return base64url(crypto.getRandomValues(new Uint8Array(24)))
131
+ }
132
+
133
+ export interface PkcePair { verifier: string; challenge: string }
134
+
135
+ /** PKCE (RFC 7636), S256 only — plain is never offered. */
136
+ export async function generatePkce(): Promise<PkcePair> {
137
+ const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)))
138
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
139
+ return { verifier, challenge: base64url(new Uint8Array(digest)) }
140
+ }
141
+
142
+ // ── the authorization request ───────────────────────────────────────
143
+
144
+ export function buildAuthorizationUrl(
145
+ metadata: OidcMetadata,
146
+ params: {
147
+ clientId: string
148
+ redirectUri: string
149
+ scopes: string
150
+ state: string
151
+ nonce: string
152
+ codeChallenge: string
153
+ },
154
+ ): string {
155
+ const url = new URL(metadata.authorization_endpoint)
156
+ url.searchParams.set('response_type', 'code')
157
+ url.searchParams.set('client_id', params.clientId)
158
+ url.searchParams.set('redirect_uri', params.redirectUri)
159
+ url.searchParams.set('scope', params.scopes)
160
+ url.searchParams.set('state', params.state)
161
+ url.searchParams.set('nonce', params.nonce)
162
+ url.searchParams.set('code_challenge', params.codeChallenge)
163
+ url.searchParams.set('code_challenge_method', 'S256')
164
+ return url.toString()
165
+ }
166
+
167
+ // ── the code exchange ───────────────────────────────────────────────
168
+
169
+ export interface OidcTokenResponse {
170
+ id_token: string
171
+ access_token?: string
172
+ token_type?: string
173
+ expires_in?: number
174
+ }
175
+
176
+ export async function exchangeCode(
177
+ metadata: OidcMetadata,
178
+ params: {
179
+ clientId: string
180
+ clientSecret?: string
181
+ code: string
182
+ redirectUri: string
183
+ codeVerifier: string
184
+ },
185
+ fetchImpl: typeof fetch = fetch,
186
+ ): Promise<OidcTokenResponse> {
187
+ const body = new URLSearchParams({
188
+ grant_type: 'authorization_code',
189
+ code: params.code,
190
+ redirect_uri: params.redirectUri,
191
+ client_id: params.clientId,
192
+ code_verifier: params.codeVerifier,
193
+ })
194
+ const headers: Record<string, string> = { 'content-type': 'application/x-www-form-urlencoded' }
195
+ if (params.clientSecret) {
196
+ headers.authorization = `Basic ${btoa(`${encodeURIComponent(params.clientId)}:${encodeURIComponent(params.clientSecret)}`)}`
197
+ }
198
+ let json: unknown
199
+ try {
200
+ const res = await fetchImpl(metadata.token_endpoint, { method: 'POST', headers, body })
201
+ json = await res.json()
202
+ if (!res.ok) {
203
+ const err = (json as { error?: string; error_description?: string }) ?? {}
204
+ throw new Error(`HTTP ${res.status} ${err.error ?? ''} ${err.error_description ?? ''}`.trim())
205
+ }
206
+ } catch (err) {
207
+ throw new OidcError('exchange', `the token endpoint refused the exchange: ${(err as Error).message}`)
208
+ }
209
+ const token = json as Partial<OidcTokenResponse>
210
+ if (typeof token?.id_token !== 'string') {
211
+ throw new OidcError('exchange', 'the token response carries no id_token — this flow requires the openid scope')
212
+ }
213
+ return token as OidcTokenResponse
214
+ }
215
+
216
+ // ── ID-token validation ─────────────────────────────────────────────
217
+
218
+ interface Jwk {
219
+ kty: string
220
+ kid?: string
221
+ alg?: string
222
+ use?: string
223
+ n?: string
224
+ e?: string
225
+ x?: string
226
+ y?: string
227
+ crv?: string
228
+ }
229
+
230
+ interface CachedJwks { keys: Jwk[]; fetchedAt: number }
231
+ const jwksCache = new Map<string, CachedJwks>()
232
+ const JWKS_TTL_MS = 60 * 60 * 1000
233
+
234
+ async function fetchJwks(jwksUri: string, fetchImpl: typeof fetch, force: boolean): Promise<Jwk[]> {
235
+ const cached = jwksCache.get(jwksUri)
236
+ if (!force && cached && Date.now() - cached.fetchedAt < JWKS_TTL_MS) return cached.keys
237
+ let body: unknown
238
+ try {
239
+ const res = await fetchImpl(jwksUri)
240
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
241
+ body = await res.json()
242
+ } catch (err) {
243
+ throw new OidcError('token_signature', `could not fetch the signing keys (${jwksUri}): ${(err as Error).message}`)
244
+ }
245
+ const keys = (body as { keys?: Jwk[] })?.keys
246
+ if (!Array.isArray(keys)) {
247
+ throw new OidcError('token_signature', `the JWKS at ${jwksUri} carries no keys array`)
248
+ }
249
+ jwksCache.set(jwksUri, { keys, fetchedAt: Date.now() })
250
+ return keys
251
+ }
252
+
253
+ export interface OidcIdTokenClaims {
254
+ iss: string
255
+ sub: string
256
+ aud: string | string[]
257
+ exp: number
258
+ iat?: number
259
+ nonce?: string
260
+ azp?: string
261
+ email?: string
262
+ email_verified?: boolean
263
+ name?: string
264
+ [claim: string]: unknown
265
+ }
266
+
267
+ const EXPIRY_LEEWAY_MS = 60_000
268
+
269
+ /**
270
+ * Validate the ID token: signature against the IdP's JWKS, then the
271
+ * iss / aud / exp / nonce claims. Returns the claims on success, throws
272
+ * OidcError (a plain `reason`, never the raw crypto failure) otherwise.
273
+ */
274
+ export async function validateIdToken(
275
+ idToken: string,
276
+ expectations: { issuer: string; clientId: string; nonce: string; jwksUri: string },
277
+ fetchImpl: typeof fetch = fetch,
278
+ ): Promise<OidcIdTokenClaims> {
279
+ const parts = idToken.split('.')
280
+ if (parts.length !== 3) {
281
+ throw new OidcError('token_malformed', 'the ID token is not a three-part JWT')
282
+ }
283
+ let header: { alg?: string; kid?: string }
284
+ let claims: OidcIdTokenClaims
285
+ try {
286
+ header = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[0]!)))
287
+ claims = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[1]!))) as OidcIdTokenClaims
288
+ } catch {
289
+ throw new OidcError('token_malformed', 'the ID token header/claims are not JSON')
290
+ }
291
+ if (header.alg !== 'RS256' && header.alg !== 'ES256') {
292
+ throw new OidcError('token_alg', `the ID token uses ${header.alg ?? 'no declared algorithm'} — only RS256 and ES256 are verified`)
293
+ }
294
+
295
+ // Signature: pick the JWKS key by kid (and algorithm family); a first
296
+ // miss refetches once (key rotation), then fails honestly.
297
+ const signedContent = new TextEncoder().encode(`${parts[0]}.${parts[1]}`)
298
+ const signature = base64urlDecode(parts[2]!)
299
+ let verified = false
300
+ for (const force of [false, true]) {
301
+ const keys = await fetchJwks(expectations.jwksUri, fetchImpl, force)
302
+ const candidates = keys.filter(k =>
303
+ (!header.kid || k.kid === header.kid)
304
+ && (header.alg === 'RS256' ? k.kty === 'RSA' : k.kty === 'EC'),
305
+ )
306
+ for (const jwk of candidates) {
307
+ try {
308
+ const key = await crypto.subtle.importKey(
309
+ 'jwk',
310
+ jwk as JsonWebKey,
311
+ header.alg === 'RS256'
312
+ ? { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }
313
+ : { name: 'ECDSA', namedCurve: 'P-256' },
314
+ false,
315
+ ['verify'],
316
+ )
317
+ verified = await crypto.subtle.verify(
318
+ header.alg === 'RS256'
319
+ ? { name: 'RSASSA-PKCS1-v1_5' }
320
+ : { name: 'ECDSA', hash: 'SHA-256' },
321
+ key,
322
+ signature as BufferSource,
323
+ signedContent,
324
+ )
325
+ } catch {
326
+ verified = false // an unimportable key is a miss, never a pass
327
+ }
328
+ if (verified) break
329
+ }
330
+ if (verified) break
331
+ }
332
+ if (!verified) {
333
+ throw new OidcError('token_signature', 'the ID token signature does not verify against the issuer’s published keys')
334
+ }
335
+
336
+ if (claims.iss?.replace(/\/$/, '') !== expectations.issuer.replace(/\/$/, '')) {
337
+ throw new OidcError('token_issuer', `the ID token’s issuer (${claims.iss ?? 'none'}) is not the configured issuer`)
338
+ }
339
+ const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]
340
+ if (!audiences.includes(expectations.clientId)) {
341
+ throw new OidcError('token_audience', 'the ID token was not issued for this application (audience mismatch)')
342
+ }
343
+ if (audiences.length > 1 && claims.azp && claims.azp !== expectations.clientId) {
344
+ throw new OidcError('token_audience', 'the ID token’s authorized party is not this application')
345
+ }
346
+ if (typeof claims.exp !== 'number' || claims.exp * 1000 + EXPIRY_LEEWAY_MS < Date.now()) {
347
+ throw new OidcError('token_expired', 'the ID token has expired')
348
+ }
349
+ if (claims.nonce !== expectations.nonce) {
350
+ throw new OidcError('token_nonce', 'the ID token’s nonce does not match the request (replay guard)')
351
+ }
352
+ return claims
353
+ }
354
+
355
+ // ── RP-initiated logout ─────────────────────────────────────────────
356
+
357
+ /** The IdP's end-session URL, or null when the metadata declares no
358
+ * end_session_endpoint (the local sign-out then stands alone). */
359
+ export function buildEndSessionUrl(
360
+ metadata: OidcMetadata,
361
+ params: { idTokenHint?: string | null; clientId: string; postLogoutRedirectUri: string },
362
+ ): string | null {
363
+ if (!metadata.end_session_endpoint) return null
364
+ const url = new URL(metadata.end_session_endpoint)
365
+ if (params.idTokenHint) url.searchParams.set('id_token_hint', params.idTokenHint)
366
+ url.searchParams.set('client_id', params.clientId)
367
+ url.searchParams.set('post_logout_redirect_uri', params.postLogoutRedirectUri)
368
+ return url.toString()
369
+ }
@@ -0,0 +1,83 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The node half of the deployment-profile loader (TODO.federation/01):
3
+ // resolves the profile YAML from disk — the INSTANCE_PROFILE env path
4
+ // when declared, else <consumer root>/instance.profile.yaml when
5
+ // present, else the built-in hub default (the byte-identical-today
6
+ // posture) — parses it through the worker-safe core (./profile.ts),
7
+ // and installs it into the isolate slot the seed plans and /api/config
8
+ // read.
9
+ //
10
+ // The DEFAULT path anchors at the CONSUMER's root, never this
11
+ // package's own: the composition root passes its `root` (the smart
12
+ // monorepo's browser/server/profile-node.ts anchors it at the repo
13
+ // root from its own module location); an unrooted call falls back to
14
+ // the process's working directory. A kernel resolving its own
15
+ // import.meta.url here would look under packages/platform-server/
16
+ // (node_modules/@oimlsmart/platform-server/ once published) — a place
17
+ // no deployment profile ever lives, so a consumer's default file at
18
+ // its own root would be silently ignored (the wave-01 regression the
19
+ // root parameter fixes).
20
+ //
21
+ // NODE-ONLY (node:fs) — the Worker bundle never imports this module;
22
+ // the Worker's loader is profile.ts's resolveInstanceProfileFromEnv
23
+ // (the inline INSTANCE_PROFILE_YAML binding).
24
+ // ═══════════════════════════════════════════════════════════════════
25
+
26
+ import { existsSync, readFileSync } from 'node:fs'
27
+ import { isAbsolute, join, resolve } from 'node:path'
28
+ import {
29
+ installInstanceProfile,
30
+ parseInstanceProfile,
31
+ type InstanceProfile,
32
+ } from '../profile'
33
+
34
+ export interface LoadNodeProfileOptions {
35
+ /** Override the file resolution (tests); still installs + memoizes. */
36
+ path?: string
37
+ /** The consumer's root for the default profile path
38
+ * (<root>/instance.profile.yaml) — the composition root's context.
39
+ * Defaults to the process's working directory. NEVER this package's
40
+ * own root: the kernel ships no default profile and never reads one
41
+ * from its own tree. */
42
+ root?: string
43
+ /** Re-read even when a profile is already loaded (tests). */
44
+ force?: boolean
45
+ }
46
+
47
+ let loaded: InstanceProfile | null = null
48
+
49
+ /** The profile file this process boots from: $INSTANCE_PROFILE (relative
50
+ * paths resolve against the CWD — the `browser/` dir under npm run dev)
51
+ * when declared, else <root>/instance.profile.yaml when it exists
52
+ * (the consumer's root, defaulting to the CWD), else no file (the hub
53
+ * default). A DECLARED-but-missing file fails the boot — a misdeclared
54
+ * deployment must never silently boot as the hub. */
55
+ export function resolveNodeProfilePath(env: NodeJS.ProcessEnv = process.env, root: string = process.cwd()): string | null {
56
+ const declared = env.INSTANCE_PROFILE
57
+ if (declared) {
58
+ const path = isAbsolute(declared) ? declared : resolve(process.cwd(), declared)
59
+ if (!existsSync(path)) {
60
+ throw new Error(`instance profile: INSTANCE_PROFILE points at ${path}, which does not exist`)
61
+ }
62
+ return path
63
+ }
64
+ const defaultPath = join(root, 'instance.profile.yaml')
65
+ return existsSync(defaultPath) ? defaultPath : null
66
+ }
67
+
68
+ /** Load, install, and return the effective profile (memoized per
69
+ * process). The SEED_DEMO_PERSONAS env flag forces the demo cast on
70
+ * for the ia/tl profiles (never off — the file's `demo_personas:
71
+ * false` still holds under the flag's absence). */
72
+ export function loadNodeInstanceProfile(options?: LoadNodeProfileOptions): InstanceProfile {
73
+ if (loaded && !options?.force) return loaded
74
+ const path = options?.path ?? resolveNodeProfilePath(process.env, options?.root)
75
+ const text = path === null ? undefined : readFileSync(path, 'utf-8')
76
+ const demoEnv = process.env.SEED_DEMO_PERSONAS
77
+ const profile = parseInstanceProfile(text, {
78
+ demoPersonas: demoEnv === 'true' || demoEnv === '1' ? true : undefined,
79
+ })
80
+ installInstanceProfile(profile)
81
+ loaded = profile
82
+ return profile
83
+ }