nucleus-core-ts 0.9.744 → 0.9.746

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 (37) hide show
  1. package/bin/cli.ts +86 -65
  2. package/bin/lib/commands/add.ts +235 -0
  3. package/bin/lib/commands/dev.ts +174 -0
  4. package/bin/lib/commands/doctor.ts +78 -0
  5. package/bin/lib/commands/init.ts +128 -0
  6. package/bin/lib/commands/validate.ts +113 -0
  7. package/bin/lib/config-builder.ts +66 -0
  8. package/bin/lib/env.ts +77 -0
  9. package/bin/lib/presets.ts +120 -0
  10. package/bin/lib/schema.ts +119 -0
  11. package/bin/lib/templates.ts +145 -0
  12. package/bin/lib/ui.ts +123 -0
  13. package/dist/bin/cli.d.ts +7 -5
  14. package/dist/bin/lib/commands/add.d.ts +1 -0
  15. package/dist/bin/lib/commands/dev.d.ts +1 -0
  16. package/dist/bin/lib/commands/doctor.d.ts +1 -0
  17. package/dist/bin/lib/commands/init.d.ts +1 -0
  18. package/dist/bin/lib/commands/validate.d.ts +8 -0
  19. package/dist/bin/lib/config-builder.d.ts +7 -0
  20. package/dist/bin/lib/env.d.ts +16 -0
  21. package/dist/bin/lib/presets.d.ts +12 -0
  22. package/dist/bin/lib/schema.d.ts +13 -0
  23. package/dist/bin/lib/templates.d.ts +3 -0
  24. package/dist/bin/lib/ui.d.ts +36 -0
  25. package/dist/index.js +6 -6
  26. package/dist/src/ElysiaPlugin/authRouteClassifier.d.ts +34 -0
  27. package/dist/src/ElysiaPlugin/authRouteClassifier.test.d.ts +1 -0
  28. package/dist/src/ElysiaPlugin/routes/auth/oauth/index.d.ts +7 -0
  29. package/dist/src/ElysiaPlugin/routes/auth/oauth/resolveSafeOAuthRedirect.test.d.ts +1 -0
  30. package/dist/src/ElysiaPlugin/routes/entity/createScope.d.ts +8 -0
  31. package/dist/src/ElysiaPlugin/routes/entity/createScope.test.d.ts +1 -0
  32. package/dist/src/ElysiaPlugin/routes/entity/filterFields.test.d.ts +1 -0
  33. package/dist/src/ElysiaPlugin/routes/entity/index.d.ts +15 -0
  34. package/dist/src/ElysiaPlugin/routes/shared/sensitiveTables.d.ts +8 -0
  35. package/dist/src/ElysiaPlugin/routes/shared/sensitiveTables.test.d.ts +1 -0
  36. package/dist/src/Services/Payment/Marketplace/MarketplaceService.d.ts +12 -0
  37. package/package.json +1 -1
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `nucleus init` — the front door. Build a config (from a preset, an existing
3
+ * file, or the interactive builder), scaffold a runnable Bun/Elysia backend,
4
+ * install deps and generate the Drizzle schema — so a new service boots in one
5
+ * command.
6
+ *
7
+ * Flags:
8
+ * --preset <minimal|api|saas|marketplace> non-interactive: seed from a preset
9
+ * --config <path> use an existing config.json (no builder)
10
+ * --app-id <id> app id (else prompted / from config)
11
+ * --output, -o <dir> target dir (default ./<appId>)
12
+ * --yes, -y no prompts (defaults to the saas preset)
13
+ * --no-install skip bun install + generate
14
+ */
15
+ import { spawnSync } from 'node:child_process'
16
+ import { existsSync, readdirSync } from 'node:fs'
17
+ import { resolve } from 'node:path'
18
+ import { banner, c, fail, info, log, ok, prompts, step, warn } from '../ui'
19
+ import { buildPreset, PRESET_NAMES, type PresetName } from '../presets'
20
+ import { buildConfigInteractive } from '../config-builder'
21
+ import { writeBackend } from '../templates'
22
+ import { crossFieldChecks } from './validate'
23
+ import { loadConfigSchema, validateAgainstSchema } from '../schema'
24
+
25
+ type Flags = { preset?: string; config?: string; appId?: string; output?: string; yes: boolean; install: boolean }
26
+
27
+ function parseFlags(args: string[]): Flags {
28
+ const f: Flags = { yes: false, install: true }
29
+ for (let i = 0; i < args.length; i++) {
30
+ const a = args[i]
31
+ if (a === '--preset') f.preset = args[++i]
32
+ else if (a === '--config') f.config = args[++i]
33
+ else if (a === '--app-id') f.appId = args[++i]
34
+ else if (a === '--output' || a === '-o') f.output = args[++i]
35
+ else if (a === '--yes' || a === '-y') f.yes = true
36
+ else if (a === '--no-install') f.install = false
37
+ }
38
+ return f
39
+ }
40
+
41
+ const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
42
+
43
+ export async function initCommand(args: string[]): Promise<number> {
44
+ const f = parseFlags(args)
45
+ const version = 'nucleus init'
46
+ banner(version, 'scaffold a runnable Nucleus backend')
47
+
48
+ let config: Record<string, unknown>
49
+ let appId: string
50
+
51
+ if (f.config) {
52
+ // Use an existing config (skip the builder).
53
+ const path = resolve(f.config)
54
+ if (!existsSync(path)) return fail(`Config not found: ${f.config}`), 1
55
+ config = JSON.parse(await Bun.file(path).text()) as Record<string, unknown>
56
+ appId = slug((config.appId as string) || f.appId || 'my-app')
57
+ } else if (f.preset || f.yes) {
58
+ const name = (f.preset || 'saas') as PresetName
59
+ if (!PRESET_NAMES.includes(name)) return fail(`Unknown preset "${name}" (choose ${PRESET_NAMES.join(', ')})`), 1
60
+ appId = slug(f.appId || 'my-app')
61
+ config = buildPreset(name, appId)
62
+ step(`Seeded a "${name}" config for ${c.cyan(appId)}`)
63
+ } else {
64
+ // Interactive.
65
+ const p = prompts()
66
+ try {
67
+ config = await buildConfigInteractive(p)
68
+ appId = slug((config.appId as string) || 'my-app')
69
+ } finally {
70
+ p.close()
71
+ }
72
+ }
73
+
74
+ // Validate before writing anything.
75
+ step('Validating config')
76
+ const res = validateAgainstSchema(config, await loadConfigSchema())
77
+ const cross = crossFieldChecks(config)
78
+ for (const e of [...res.errors, ...cross]) fail(`${c.bold(e.path || '(root)')} — ${e.message}`)
79
+ if (res.errors.length + cross.length > 0) {
80
+ fail('Config is invalid — aborting.')
81
+ return 1
82
+ }
83
+ ok('Config is valid')
84
+
85
+ // Resolve + guard the output directory.
86
+ const outDir = resolve(f.output || appId)
87
+ if (existsSync(outDir) && readdirSync(outDir).length > 0) {
88
+ if (f.yes || f.preset || f.config) {
89
+ warn(`Output dir not empty: ${outDir} — writing into it anyway (--yes/non-interactive)`)
90
+ } else {
91
+ const p = prompts()
92
+ const cont = await p.confirm(`Directory ${outDir} is not empty. Continue?`, false)
93
+ p.close()
94
+ if (!cont) return log('\nAborted.'), 0
95
+ }
96
+ }
97
+
98
+ step(`Generating backend at ${c.cyan(outDir)}`)
99
+ const missing = writeBackend(outDir, appId, config)
100
+ ok('package.json, src/index.ts, src/config.json, tsconfig, .env written')
101
+ if (missing.length) warn(`.env has ${missing.length} secret(s) to fill: ${c.dim(missing.join(', '))}`)
102
+
103
+ if (f.install) {
104
+ step('Installing dependencies')
105
+ const inst = spawnSync('bun', ['install'], { cwd: outDir, stdio: 'inherit' })
106
+ if (inst.status !== 0) {
107
+ fail('bun install failed — fix and re-run `bun install` in the project')
108
+ } else {
109
+ ok('dependencies installed')
110
+ step('Generating Drizzle schema')
111
+ const gen = spawnSync('bunx', ['nucleus-core-ts', 'generate', 'src/config.json', 'src/drizzle'], {
112
+ cwd: outDir,
113
+ stdio: 'inherit',
114
+ })
115
+ if (gen.status === 0) ok('schema generated')
116
+ else warn('schema generation skipped/failed — run `bun run generate` after setting DATABASE_URL')
117
+ }
118
+ }
119
+
120
+ log()
121
+ ok(c.green(c.bold('Project ready')))
122
+ info(`Next:`)
123
+ log(` ${c.cyan(`cd ${f.output || appId}`)}`)
124
+ if (!f.install) log(` ${c.cyan('bun install && bun run generate')}`)
125
+ log(` ${c.cyan('nucleus doctor')} ${c.dim('# verify Postgres/Redis are up')}`)
126
+ log(` ${c.cyan('bun run dev')} ${c.dim('# http://localhost:4000/docs')}`)
127
+ return 0
128
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `nucleus validate [config.json]` — validate a Nucleus config against the
3
+ * generated JSON-Schema, plus cross-field rules the schema can't express and an
4
+ * env-var reference report. Exit code 1 on any error (CI-friendly).
5
+ */
6
+ import { existsSync } from 'node:fs'
7
+ import { resolve } from 'node:path'
8
+ import { c, fail, info, log, ok, step, warn } from '../ui'
9
+ import { loadConfigSchema, validateAgainstSchema, type ValidationIssue } from '../schema'
10
+
11
+ const CANDIDATES = ['src/config.json', 'config.json', 'src/config.nucleus.json']
12
+
13
+ export function findConfigPath(arg?: string): string | null {
14
+ if (arg) return existsSync(resolve(arg)) ? resolve(arg) : null
15
+ for (const cand of CANDIDATES) if (existsSync(resolve(cand))) return resolve(cand)
16
+ return null
17
+ }
18
+
19
+ type AnyRec = Record<string, unknown>
20
+ const isOn = (s: unknown): boolean => typeof s === 'object' && s !== null && (s as AnyRec).enabled === true
21
+
22
+ /** Rules the JSON-Schema can't express (conditional dependencies between keys). */
23
+ export function crossFieldChecks(cfg: AnyRec): ValidationIssue[] {
24
+ const issues: ValidationIssue[] = []
25
+ const add = (path: string, message: string) => issues.push({ path, message })
26
+
27
+ // Genuinely-required top-level keys (no runtime default).
28
+ if (!cfg.appId || typeof cfg.appId !== 'string') add('appId', 'is required (a unique, stable app id)')
29
+ const db = cfg.database as AnyRec | undefined
30
+ if (!db || !db.url) add('database.url', 'is required (connection string or env-var name)')
31
+
32
+ const auth = cfg.authentication as AnyRec | undefined
33
+ if (auth?.enabled) {
34
+ if (!auth.mode) add('authentication.mode', 'set "full" (IDP) or "consumer" (resource server)')
35
+ if (auth.mode === 'consumer' && isOn(cfg.authorization) && !auth.idpUrl)
36
+ add('authentication.idpUrl', 'consumer mode with authorization needs idpUrl for /auth/check')
37
+ }
38
+ if (!Array.isArray(cfg.entities) || (cfg.entities as unknown[]).length === 0)
39
+ add('entities', 'no entities — no CRUD endpoints will be generated')
40
+
41
+ const payment = cfg.payment as AnyRec | undefined
42
+ if (payment?.enabled) {
43
+ if (!payment.provider) add('payment.provider', 'required ("iyzico" or "stripe") when payment.enabled')
44
+ else if (!payment[payment.provider as string]) add(`payment.${payment.provider}`, 'provider config block is missing')
45
+ }
46
+
47
+ const notif = cfg.notification as AnyRec | undefined
48
+ const channels = notif?.channels as AnyRec | undefined
49
+ if (notif?.enabled && channels) {
50
+ const tg = channels.telegram as AnyRec | undefined
51
+ if (isOn(tg) && (!tg?.botToken || !tg?.chatId)) add('notification.channels.telegram', 'enabled but botToken/chatId missing')
52
+ const wh = channels.webhook as AnyRec | undefined
53
+ if (isOn(wh) && !wh?.url) add('notification.channels.webhook', 'enabled but url missing')
54
+ if (channels.email === true && !cfg.email) add('notification.channels.email', 'enabled but no config.email provider set')
55
+ }
56
+
57
+ const chat = cfg.chat as AnyRec | undefined
58
+ const storage = cfg.storage as AnyRec | undefined
59
+ if (isOn(chat) && isOn((chat as AnyRec)?.attachments) && !(isOn(storage) && isOn((storage as AnyRec)?.cdn)))
60
+ add('chat.attachments', 'requires storage.enabled and storage.cdn.enabled')
61
+
62
+ return issues
63
+ }
64
+
65
+ const ENV_RE = /^[A-Z][A-Z0-9_]{2,}$/
66
+ const ENV_SKIP = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'TOGGLE', 'VERIFICATION', 'OPTIONS', 'HEAD'])
67
+ const ENV_SKIP_KEYS = new Set(['entities', 'externalEntities', 'seed'])
68
+
69
+ export function collectEnvRefs(v: unknown, found = new Set<string>()): Set<string> {
70
+ if (typeof v === 'string') {
71
+ if (ENV_RE.test(v) && !ENV_SKIP.has(v)) found.add(v)
72
+ } else if (Array.isArray(v)) for (const i of v) collectEnvRefs(i, found)
73
+ else if (typeof v === 'object' && v !== null)
74
+ for (const [k, val] of Object.entries(v)) if (!ENV_SKIP_KEYS.has(k)) collectEnvRefs(val, found)
75
+ return found
76
+ }
77
+
78
+ export async function validateCommand(args: string[]): Promise<number> {
79
+ const path = findConfigPath(args[0])
80
+ if (!path) {
81
+ fail(args[0] ? `Config not found: ${args[0]}` : `No config found (looked for ${CANDIDATES.join(', ')})`)
82
+ return 1
83
+ }
84
+ step(`Validating ${c.cyan(path)}`)
85
+
86
+ let cfg: AnyRec
87
+ try {
88
+ cfg = JSON.parse(await Bun.file(path).text()) as AnyRec
89
+ } catch (e) {
90
+ fail(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`)
91
+ return 1
92
+ }
93
+
94
+ const schema = await loadConfigSchema()
95
+ const res = validateAgainstSchema(cfg, schema)
96
+ const cross = crossFieldChecks(cfg)
97
+
98
+ for (const e of res.errors) fail(`${c.bold(e.path || '(root)')} — ${e.message}`)
99
+ for (const e of cross) fail(`${c.bold(e.path)} — ${e.message}`)
100
+ for (const w of res.warnings) warn(`${c.bold(w.path)} — ${w.message}`)
101
+
102
+ const envRefs = [...collectEnvRefs(cfg)].sort()
103
+ if (envRefs.length) info(`${envRefs.length} env var(s) referenced: ${c.dim(envRefs.join(', '))}`)
104
+
105
+ const errCount = res.errors.length + cross.length
106
+ log()
107
+ if (errCount === 0) {
108
+ ok(`${c.green(c.bold('Valid'))} — ${res.warnings.length} warning(s)`)
109
+ return 0
110
+ }
111
+ fail(`${c.red(c.bold(`${errCount} error(s)`))}, ${res.warnings.length} warning(s)`)
112
+ return 1
113
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Interactive config builder for `nucleus init` (TTY). Starts from a preset,
3
+ * then optionally layers on entities via a column-by-column entity builder.
4
+ * Returns a NucleusConfigOptions object ready to write to config.json.
5
+ */
6
+ import { c, info, log, ok, step } from './ui'
7
+ import type { prompts as promptsFactory } from './ui'
8
+ import { buildPreset, PRESET_NAMES, PRESET_SUMMARY, type PresetName } from './presets'
9
+
10
+ type P = ReturnType<typeof promptsFactory>
11
+ type Cfg = Record<string, unknown>
12
+
13
+ const COLUMN_TYPES = ['varchar', 'text', 'integer', 'numeric', 'boolean', 'timestamp', 'uuid', 'json', 'jsonb']
14
+
15
+ export function slug(s: string): string {
16
+ return s.toLowerCase().replace(/[^a-z0-9_]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')
17
+ }
18
+
19
+ export async function buildEntity(p: P): Promise<Cfg | null> {
20
+ const name = slug(await p.ask(' Table name (snake_case, blank to finish)'))
21
+ if (!name) return null
22
+ const columns: Cfg[] = []
23
+ log(` ${c.dim('Add columns (blank name to finish):')}`)
24
+ while (true) {
25
+ const cname = slug(await p.ask(' · column name'))
26
+ if (!cname) break
27
+ const type = await p.choose(` type of "${cname}"`, COLUMN_TYPES, 0)
28
+ const notNull = await p.confirm(' required (NOT NULL)?', false)
29
+ const col: Cfg = { name: cname, type }
30
+ if (notNull) col.notNull = true
31
+ columns.push(col)
32
+ }
33
+ if (columns.length === 0) columns.push({ name: 'name', type: 'varchar', length: 200, notNull: true })
34
+ const isPublic = await p.confirm(' make GET public (readable without auth)?', false)
35
+ const bulk = await p.confirm(' enable bulk endpoints?', false)
36
+ const entity: Cfg = { table_name: name, add_base_columns: true, columns }
37
+ if (isPublic) entity.is_public = { GET: true }
38
+ if (bulk) entity.bulk_endpoints_enabled = true
39
+ return entity
40
+ }
41
+
42
+ export async function buildConfigInteractive(p: P): Promise<Cfg> {
43
+ step('Build your config')
44
+ const appId = slug((await p.askRequired('App id (unique, stable — e.g. shop-api)')) || 'my-app')
45
+
46
+ log(`\n ${c.bold('Start from a preset:')}`)
47
+ for (const name of PRESET_NAMES) log(` ${c.cyan(name)} — ${c.dim(PRESET_SUMMARY[name])}`)
48
+ const preset = (await p.choose('\n Preset', [...PRESET_NAMES], 2)) as PresetName
49
+
50
+ const cfg = buildPreset(preset, appId)
51
+ ok(`Seeded a "${preset}" config`)
52
+
53
+ if (await p.confirm('\n Replace the example entity with your own?', false)) {
54
+ const entities: Cfg[] = []
55
+ let e = await buildEntity(p)
56
+ while (e) {
57
+ entities.push(e)
58
+ ok(`Added entity "${(e as { table_name: string }).table_name}"`)
59
+ e = await buildEntity(p)
60
+ }
61
+ if (entities.length > 0) cfg.entities = entities
62
+ }
63
+
64
+ info(`Deeper options: edit config.json after — the $schema ref gives editor autocomplete for every key.`)
65
+ return cfg
66
+ }
package/bin/lib/env.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared local-environment helpers for the CLI: read a project .env, parse a
3
+ * host:port, and TCP-probe reachability. Used by `doctor` and `dev`.
4
+ */
5
+ import { existsSync, readFileSync } from 'node:fs'
6
+ import { resolve } from 'node:path'
7
+
8
+ /** Read a project .env (KEY=VALUE) merged over process.env. */
9
+ export function readEnv(cwd = process.cwd()): Record<string, string> {
10
+ const env: Record<string, string> = { ...(process.env as Record<string, string>) }
11
+ const envPath = resolve(cwd, '.env')
12
+ if (existsSync(envPath)) {
13
+ try {
14
+ for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
15
+ const m = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)\s*$/)
16
+ if (m?.[1]) env[m[1]] = (m[2] ?? '').replace(/^["']|["']$/g, '')
17
+ }
18
+ } catch {
19
+ /* ignore */
20
+ }
21
+ }
22
+ return env
23
+ }
24
+
25
+ export function hostPort(url: string | undefined, defHost: string, defPort: number): { host: string; port: number } {
26
+ if (!url) return { host: defHost, port: defPort }
27
+ try {
28
+ const u = new URL(url)
29
+ return { host: u.hostname || defHost, port: u.port ? Number(u.port) : defPort }
30
+ } catch {
31
+ return { host: defHost, port: defPort }
32
+ }
33
+ }
34
+
35
+ export async function tcpReachable(hostname: string, port: number, timeoutMs = 1500): Promise<boolean> {
36
+ return new Promise((res) => {
37
+ let done = false
38
+ const finish = (v: boolean) => {
39
+ if (!done) {
40
+ done = true
41
+ res(v)
42
+ }
43
+ }
44
+ const t = setTimeout(() => finish(false), timeoutMs)
45
+ Bun.connect({
46
+ hostname,
47
+ port,
48
+ socket: {
49
+ data() {},
50
+ open(sock) {
51
+ clearTimeout(t)
52
+ sock.end()
53
+ finish(true)
54
+ },
55
+ error() {
56
+ clearTimeout(t)
57
+ finish(false)
58
+ },
59
+ connectError() {
60
+ clearTimeout(t)
61
+ finish(false)
62
+ },
63
+ },
64
+ }).catch(() => {
65
+ clearTimeout(t)
66
+ finish(false)
67
+ })
68
+ })
69
+ }
70
+
71
+ /** Resolve Postgres + Redis host/port from a project's env. */
72
+ export function pgConn(env: Record<string, string>) {
73
+ return hostPort(env.DATABASE_URL, 'localhost', 5432)
74
+ }
75
+ export function redisConn(env: Record<string, string>) {
76
+ return hostPort(env.REDIS_URL, env.REDIS_HOST || 'localhost', Number(env.REDIS_PORT) || 6379)
77
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Starter config presets for `nucleus init --preset <name>`. Each returns a
3
+ * complete, schema-valid NucleusConfigOptions object (as a plain record) for a
4
+ * given appId — a sensible, runnable starting point the user then refines.
5
+ * Secrets are env-var NAMES (resolved at boot), never literals.
6
+ */
7
+ export type PresetName = 'minimal' | 'api' | 'saas' | 'marketplace'
8
+ export const PRESET_NAMES: PresetName[] = ['minimal', 'api', 'saas', 'marketplace']
9
+
10
+ type Cfg = Record<string, unknown>
11
+
12
+ const exampleEntity = {
13
+ table_name: 'products',
14
+ add_base_columns: true,
15
+ bulk_endpoints_enabled: true,
16
+ is_public: { GET: true },
17
+ columns: [
18
+ { name: 'title', type: 'varchar', length: 200, notNull: true },
19
+ { name: 'description', type: 'text' },
20
+ { name: 'price', type: 'numeric', precision: 10, scale: 2 },
21
+ { name: 'in_stock', type: 'boolean', default: true },
22
+ ],
23
+ }
24
+
25
+ function base(appId: string, mode: 'development' | 'production'): Cfg {
26
+ return {
27
+ $schema: './config.nucleus.json',
28
+ appId,
29
+ mode,
30
+ database: { url: 'DATABASE_URL' },
31
+ redis: { host: 'REDIS_HOST', port: 'REDIS_PORT', withDapr: false },
32
+ logging: { level: mode === 'development' ? 'debug' : 'info' },
33
+ entities: [exampleEntity],
34
+ }
35
+ }
36
+
37
+ const fullAuth = () => ({
38
+ enabled: true,
39
+ mode: 'full',
40
+ defaultRole: 'basic',
41
+ accessToken: { secret: 'ACCESS_TOKEN_SECRET', expiresIn: '15m' },
42
+ refreshToken: { secret: 'REFRESH_TOKEN_SECRET', expiresIn: '30d' },
43
+ sessionToken: { secret: 'SESSION_TOKEN_SECRET' },
44
+ login: { enabled: true },
45
+ register: { enabled: true, createProfileOnRegister: true },
46
+ logout: { enabled: true },
47
+ refresh: { enabled: true },
48
+ passwordReset: { enabled: true },
49
+ })
50
+
51
+ export function buildPreset(name: PresetName, appId: string): Cfg {
52
+ switch (name) {
53
+ case 'minimal':
54
+ return { ...base(appId, 'development'), authentication: { enabled: false } }
55
+
56
+ case 'api':
57
+ return {
58
+ ...base(appId, 'production'),
59
+ authentication: { enabled: true, mode: 'consumer', idpUrl: 'IDP_URL' },
60
+ authorization: { enabled: true, autoSeedClaims: true },
61
+ rateLimit: { enabled: true, strategy: 'sliding-window' },
62
+ monitoring: { enabled: true },
63
+ }
64
+
65
+ case 'saas':
66
+ return {
67
+ ...base(appId, 'production'),
68
+ database: { url: 'DATABASE_URL', isMultiTenant: true, tenantResolution: 'subdomain' },
69
+ authentication: {
70
+ ...fullAuth(),
71
+ register: {
72
+ enabled: true,
73
+ createProfileOnRegister: true,
74
+ emailVerification: { enabled: true },
75
+ },
76
+ sessions: { maxActiveSessions: 5, notifyOnNewDevice: true },
77
+ },
78
+ authorization: { enabled: true, autoSeedClaims: true },
79
+ email: { provider: 'gmail', gmail: { json_file_path: 'GMAIL_SA_JSON', from_email: 'FROM_EMAIL', from_name: appId } },
80
+ storage: {
81
+ enabled: true,
82
+ maxFileSizeBytes: 10485760,
83
+ cdn: { enabled: true, cacheMaxAge: 86400 },
84
+ },
85
+ notification: { enabled: true, channels: { portal: true, email: true } },
86
+ monitoring: { enabled: true },
87
+ audit: { enabled: true },
88
+ rateLimit: { enabled: true, strategy: 'sliding-window' },
89
+ }
90
+
91
+ case 'marketplace': {
92
+ const saas = buildPreset('saas', appId)
93
+ return {
94
+ ...saas,
95
+ payment: {
96
+ enabled: true,
97
+ provider: 'stripe',
98
+ defaultCurrency: 'usd',
99
+ threeDSecureEnabled: true,
100
+ savedMethodsEnabled: true,
101
+ subMerchantsEnabled: true,
102
+ stripe: { secretKey: 'STRIPE_SECRET_KEY', webhookSecret: 'STRIPE_WEBHOOK_SECRET' },
103
+ marketplace: {
104
+ defaultSettlementDelayDays: 7,
105
+ reserveRate: 0.1,
106
+ minimumPayoutAmount: 1000,
107
+ defaultCurrency: 'usd',
108
+ },
109
+ },
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ export const PRESET_SUMMARY: Record<PresetName, string> = {
116
+ minimal: 'auth off, 1 example entity — the smallest runnable API',
117
+ api: 'consumer-mode (resource server) + authorization + rate-limit + monitoring',
118
+ saas: 'full auth (login/register/email-verify) + multi-tenant + email + storage/CDN + notifications + audit',
119
+ marketplace: 'the SaaS stack + Stripe payments & the marketplace money layer (splits/reserves/payouts)',
120
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Loads the generated Nucleus config JSON-Schema (`schemas/config.nucleus.json`,
3
+ * produced from `NucleusConfigOptions`) and validates a config object against it.
4
+ *
5
+ * A deliberately small, dependency-free validator covering the subset the
6
+ * generated schema actually uses: type, enum, required, properties, items,
7
+ * $ref (to #/$defs|#/definitions), and oneOf/anyOf. Unknown properties are NOT
8
+ * treated as errors (the framework tolerates forward-compatible extra keys), but
9
+ * they are collected as warnings so typos surface.
10
+ */
11
+ import { dirname, join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url))
15
+ // bin/lib -> package root
16
+ export const SCHEMA_PATH = join(here, '..', '..', 'schemas', 'config.nucleus.json')
17
+
18
+ export type JsonSchema = Record<string, unknown>
19
+ export type ValidationIssue = { path: string; message: string }
20
+ export type ValidationResult = { valid: boolean; errors: ValidationIssue[]; warnings: ValidationIssue[] }
21
+
22
+ export async function loadConfigSchema(): Promise<JsonSchema> {
23
+ const raw = await Bun.file(SCHEMA_PATH).text()
24
+ return JSON.parse(raw) as JsonSchema
25
+ }
26
+
27
+ const typeOf = (v: unknown): string => {
28
+ if (v === null) return 'null'
29
+ if (Array.isArray(v)) return 'array'
30
+ return typeof v // string | number | boolean | object
31
+ }
32
+
33
+ const matchesType = (v: unknown, t: string): boolean => {
34
+ if (t === 'integer') return typeof v === 'number' && Number.isInteger(v)
35
+ if (t === 'number') return typeof v === 'number'
36
+ return typeOf(v) === t
37
+ }
38
+
39
+ function resolveRef(root: JsonSchema, ref: string): JsonSchema | null {
40
+ if (!ref.startsWith('#/')) return null
41
+ let node: unknown = root
42
+ for (const part of ref.slice(2).split('/')) {
43
+ node = (node as Record<string, unknown>)?.[decodeURIComponent(part)]
44
+ if (node == null) return null
45
+ }
46
+ return node as JsonSchema
47
+ }
48
+
49
+ function validateNode(
50
+ value: unknown,
51
+ schema: JsonSchema,
52
+ root: JsonSchema,
53
+ path: string,
54
+ out: ValidationResult
55
+ ): void {
56
+ if (typeof schema.$ref === 'string') {
57
+ const resolved = resolveRef(root, schema.$ref)
58
+ if (resolved) return validateNode(value, resolved, root, path, out)
59
+ return
60
+ }
61
+
62
+ // oneOf / anyOf — pass if any branch validates cleanly.
63
+ for (const key of ['oneOf', 'anyOf'] as const) {
64
+ const branches = schema[key] as JsonSchema[] | undefined
65
+ if (Array.isArray(branches)) {
66
+ const anyValid = branches.some((b) => {
67
+ const probe: ValidationResult = { valid: true, errors: [], warnings: [] }
68
+ validateNode(value, b, root, path, probe)
69
+ return probe.errors.length === 0
70
+ })
71
+ if (!anyValid) out.errors.push({ path, message: `does not match any allowed ${key} variant` })
72
+ return
73
+ }
74
+ }
75
+
76
+ const types = schema.type
77
+ if (types !== undefined) {
78
+ const list = Array.isArray(types) ? (types as string[]) : [types as string]
79
+ if (!list.some((t) => matchesType(value, t))) {
80
+ out.errors.push({ path, message: `expected ${list.join(' | ')}, got ${typeOf(value)}` })
81
+ return // wrong type — deeper checks would be noise
82
+ }
83
+ }
84
+
85
+ if (Array.isArray(schema.enum) && !schema.enum.some((e) => e === value)) {
86
+ out.errors.push({ path, message: `must be one of ${schema.enum.map((e) => JSON.stringify(e)).join(', ')}` })
87
+ }
88
+
89
+ if (typeOf(value) === 'object') {
90
+ const obj = value as Record<string, unknown>
91
+ const props = (schema.properties as Record<string, JsonSchema>) ?? {}
92
+ for (const req of (schema.required as string[]) ?? []) {
93
+ // The generated schema marks every non-optional TS field `required`, but
94
+ // most nucleus config fields have RUNTIME defaults — so a missing "required"
95
+ // field is a hint (the runtime fills it), not an error. Genuinely-critical
96
+ // requireds (appId, database, entities, conditional feature keys) are
97
+ // enforced as errors by the cross-field checks instead.
98
+ if (!(req in obj)) out.warnings.push({ path: path ? `${path}.${req}` : req, message: 'not set (uses the runtime default)' })
99
+ }
100
+ for (const [k, v] of Object.entries(obj)) {
101
+ const childPath = path ? `${path}.${k}` : k
102
+ if (props[k]) validateNode(v, props[k], root, childPath, out)
103
+ else if (schema.additionalProperties === false)
104
+ out.warnings.push({ path: childPath, message: 'unknown property (not in schema)' })
105
+ }
106
+ }
107
+
108
+ if (typeOf(value) === 'array' && schema.items) {
109
+ const itemSchema = schema.items as JsonSchema
110
+ ;(value as unknown[]).forEach((item, i) => validateNode(item, itemSchema, root, `${path}[${i}]`, out))
111
+ }
112
+ }
113
+
114
+ export function validateAgainstSchema(config: unknown, schema: JsonSchema): ValidationResult {
115
+ const out: ValidationResult = { valid: true, errors: [], warnings: [] }
116
+ validateNode(config, schema, schema, '', out)
117
+ out.valid = out.errors.length === 0
118
+ return out
119
+ }