nucleus-core-ts 0.9.779 → 0.9.780

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.9.779",
3
+ "version": "0.9.780",
4
4
  "description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
5
5
  "author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -19,6 +19,8 @@
19
19
  "infra/templates",
20
20
  "infra/scripts/generate-project.ts",
21
21
  "src/system.tables.json",
22
+ "src/Services/SchemaTables/resolveEntities.ts",
23
+ "src/Services/Logger/auditTaxonomy.ts",
22
24
  "public",
23
25
  "LICENSE"
24
26
  ],
@@ -0,0 +1,84 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
3
+ import { dirname, join, relative, resolve } from 'node:path'
4
+
5
+ /**
6
+ * Guard: everything `scripts/` imports must actually be IN the npm tarball.
7
+ *
8
+ * The scripts in this package are shipped as raw TypeScript and executed inside a
9
+ * consumer's node_modules (`bunx nucleus-generate` runs scripts/generate-schema.ts
10
+ * during their Docker build). `package.json#files` ships `scripts` and `dist` but
11
+ * NOT `src` — so a relative import that reaches into src/ resolves fine in this
12
+ * repo and fails in every consumer with
13
+ *
14
+ * Cannot find module '../src/…' from '/app/node_modules/nucleus-core-ts/scripts/…'
15
+ *
16
+ * That shipped in 0.9.779 and broke the Docker build of every app that generates
17
+ * its schema at build time. Local tests could not see it: the failure only exists
18
+ * in the packaged layout.
19
+ */
20
+ const ROOT = join(import.meta.dir, '..')
21
+ const FILES: string[] = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).files
22
+
23
+ /**
24
+ * Scripts only ever run from THIS repo by a maintainer (release tooling). They may
25
+ * read repo-only files like nucleus.config.ts, which is deliberately not shipped.
26
+ */
27
+ const MAINTAINER_ONLY = new Set(['build.ts', 'publish.ts', 'version.ts'])
28
+
29
+ /** True when `relPath` (repo-relative) is covered by a package.json#files entry. */
30
+ function isShipped(relPath: string): boolean {
31
+ return FILES.some((entry) => relPath === entry || relPath.startsWith(`${entry}/`))
32
+ }
33
+
34
+ function scriptFiles(): string[] {
35
+ return readdirSync(join(ROOT, 'scripts'))
36
+ .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts') && !MAINTAINER_ONLY.has(f))
37
+ .map((f) => join(ROOT, 'scripts', f))
38
+ }
39
+
40
+ function relativeImports(file: string): string[] {
41
+ const src = readFileSync(file, 'utf-8')
42
+ const out: string[] = []
43
+ for (const m of src.matchAll(/(?:from|import)\s+['"](\.[^'"]+)['"]/g)) {
44
+ const spec = m[1]
45
+ if (spec) out.push(spec)
46
+ }
47
+ return out
48
+ }
49
+
50
+ /** Resolve an extensionless TS import the way Bun does. */
51
+ function resolveImport(fromFile: string, spec: string): string | null {
52
+ const base = resolve(dirname(fromFile), spec)
53
+ for (const candidate of [base, `${base}.ts`, `${base}.json`, join(base, 'index.ts')]) {
54
+ if (existsSync(candidate)) return candidate
55
+ }
56
+ return null
57
+ }
58
+
59
+ describe('shipped scripts only import shipped files', () => {
60
+ test('package.json#files does not include the whole src directory', () => {
61
+ // If this ever changes the guard below becomes vacuous — fail loudly instead.
62
+ expect(FILES).not.toContain('src')
63
+ })
64
+
65
+ test('every relative import of every shipped script is in the tarball', () => {
66
+ const missing: string[] = []
67
+
68
+ for (const file of scriptFiles()) {
69
+ for (const spec of relativeImports(file)) {
70
+ const target = resolveImport(file, spec)
71
+ // Unresolvable specifiers come from the code these scripts EMIT (the schema
72
+ // generator writes `import … from './schema'` into its output), not from the
73
+ // script's own imports. Only real imports matter here.
74
+ if (!target) continue
75
+ const rel = relative(ROOT, target)
76
+ if (!isShipped(rel)) {
77
+ missing.push(`${relative(ROOT, file)} -> ${rel} (not in package.json#files)`)
78
+ }
79
+ }
80
+ }
81
+
82
+ expect(missing).toEqual([])
83
+ })
84
+ })
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Audit taxonomy — the single source of truth for classifying audit_logs rows by
3
+ * security severity and category, and for deciding which routine outcomes are
4
+ * noise that should NOT be persisted by default.
5
+ *
6
+ * Why this exists: the auth middleware writes an audit row on every rejected
7
+ * request. A logged-out browser tab polling an authenticated endpoint (e.g.
8
+ * `/auth/me`) therefore produces one "No session token" row per poll — an HTTP
9
+ * outcome with no security signal — which numerically swamps the real events
10
+ * (logins, CRUD, admin actions). Classifying every row by severity/category and
11
+ * suppressing the routine credential-absence reasons by default keeps the
12
+ * Security Logs view high-signal while preserving every genuine security event.
13
+ */
14
+
15
+ export type AuditSeverity = 'info' | 'low' | 'medium' | 'high' | 'critical'
16
+
17
+ export type AuditCategory =
18
+ | 'auth' // authentication outcomes (login/logout/register, token/session checks)
19
+ | 'authz' // authorization outcomes (claims/cohort/role denials)
20
+ | 'data_change' // entity CREATE/UPDATE/PATCH/DELETE
21
+ | 'admin' // privileged operations (impersonate, hard-delete, change-user-id, provision)
22
+ | 'config' // server/configuration problems surfaced on a request
23
+ | 'security_anomaly' // suspicious: forged/tampered token, tenant mismatch, revoked-token reuse
24
+ | 'system' // internal/system-originated
25
+
26
+ /** Numeric ordering so a configured `minSeverity` can drop everything below it. */
27
+ export const AUDIT_SEVERITY_RANK: Record<AuditSeverity, number> = {
28
+ info: 0,
29
+ low: 1,
30
+ medium: 2,
31
+ high: 3,
32
+ critical: 4,
33
+ }
34
+
35
+ export function severityRank(severity: AuditSeverity | undefined): number {
36
+ return AUDIT_SEVERITY_RANK[severity ?? 'info'] ?? 0
37
+ }
38
+
39
+ /**
40
+ * Routine, NON-security auth outcomes — the steady state of any unauthenticated
41
+ * or stale client. These carry no actor identity and no attack signal, so they
42
+ * are suppressed from audit_logs by default (still emitted at debug level). The
43
+ * strings MUST match the `summary` passed at the middleware reject sites in
44
+ * `src/ElysiaPlugin/index.ts`.
45
+ */
46
+ export const ROUTINE_AUTH_REASONS = [
47
+ 'No session token',
48
+ 'Invalid session',
49
+ 'Session expired',
50
+ 'Session inactive timeout',
51
+ 'Authentication secrets not defined',
52
+ ] as const
53
+
54
+ /** Default value for `audit.suppressReasons` — set to `[]` to capture everything (legacy). */
55
+ export const DEFAULT_AUDIT_SUPPRESS_REASONS: string[] = [...ROUTINE_AUTH_REASONS]
56
+
57
+ /**
58
+ * Explicit severity/category for each known auth-middleware reject reason. The
59
+ * middleware passes these via `toAudit(...)` so they don't depend on string
60
+ * re-classification. Reasons absent here fall back to {@link classifyAudit}.
61
+ */
62
+ export const AUTH_FAILURE_CLASSIFICATION: Record<
63
+ string,
64
+ { severity: AuditSeverity; category: AuditCategory }
65
+ > = {
66
+ 'No session token': { severity: 'low', category: 'auth' },
67
+ 'Invalid session': { severity: 'low', category: 'auth' },
68
+ 'Session expired': { severity: 'low', category: 'auth' },
69
+ 'Session inactive timeout': { severity: 'low', category: 'auth' },
70
+ 'Authentication secrets not defined': { severity: 'critical', category: 'config' },
71
+ 'Session revoked': { severity: 'high', category: 'security_anomaly' },
72
+ 'Invalid or missing access token': { severity: 'medium', category: 'auth' },
73
+ 'Access token tenant binding failed': { severity: 'high', category: 'security_anomaly' },
74
+ 'Invalid API key': { severity: 'medium', category: 'auth' },
75
+ 'Cohort expired': { severity: 'medium', category: 'authz' },
76
+ }
77
+
78
+ /**
79
+ * Derive a sensible default severity/category from an audit `operation` (and
80
+ * optional `summary`) when the call site did not provide them explicitly. This
81
+ * lets the ~20 route audit sites get correct classification for free, while the
82
+ * middleware still passes explicit tags via {@link AUTH_FAILURE_CLASSIFICATION}.
83
+ */
84
+ export function classifyAudit(
85
+ operation: string,
86
+ summary?: string
87
+ ): { severity: AuditSeverity; category: AuditCategory } {
88
+ const op = (operation || '').toUpperCase()
89
+
90
+ // Dynamic API-key rejection ("API key rejected: <reason>").
91
+ if (summary?.startsWith('API key rejected')) {
92
+ return { severity: 'medium', category: 'auth' }
93
+ }
94
+
95
+ // Known auth-failure reasons (also covers middleware sites that pass operation
96
+ // = HTTP method but a recognizable summary).
97
+ if (summary && AUTH_FAILURE_CLASSIFICATION[summary]) {
98
+ return AUTH_FAILURE_CLASSIFICATION[summary]
99
+ }
100
+
101
+ // Privileged/admin operations. IMPERSONATE is matched by prefix because the
102
+ // routes write IMPERSONATE_START / IMPERSONATE_STOP (never a bare 'IMPERSONATE') —
103
+ // an exact match here silently dropped impersonation to info/data_change.
104
+ if (
105
+ op.startsWith('IMPERSONATE') ||
106
+ op === 'HARD_DELETE' ||
107
+ op === 'CHANGE_USER_ID' ||
108
+ op === 'PROVISION' ||
109
+ op === 'RESTORE' ||
110
+ op.startsWith('ADMIN_')
111
+ ) {
112
+ return { severity: 'high', category: 'admin' }
113
+ }
114
+
115
+ // Authentication lifecycle.
116
+ if (op.endsWith('_FAILED')) {
117
+ return { severity: 'medium', category: 'auth' }
118
+ }
119
+ if (op.startsWith('PASSWORD')) {
120
+ return { severity: 'medium', category: 'auth' }
121
+ }
122
+ if (
123
+ op === 'LOGIN' ||
124
+ op === 'LOGOUT' ||
125
+ op === 'REGISTER' ||
126
+ op === 'OAUTH_LOGIN' ||
127
+ op === 'MAGIC_LINK' ||
128
+ op === 'INVITE'
129
+ ) {
130
+ return { severity: 'info', category: 'auth' }
131
+ }
132
+
133
+ // Entity data changes.
134
+ if (
135
+ op === 'CREATE' ||
136
+ op === 'UPDATE' ||
137
+ op === 'PATCH' ||
138
+ op === 'DELETE' ||
139
+ op === 'TOGGLE' ||
140
+ op === 'VERIFICATION'
141
+ ) {
142
+ return { severity: 'info', category: 'data_change' }
143
+ }
144
+
145
+ // Raw HTTP method with no recognized summary => an untagged middleware reject.
146
+ if (op === 'GET' || op === 'POST' || op === 'PUT' || op === 'PATCH' || op === 'HEAD') {
147
+ return { severity: 'low', category: 'auth' }
148
+ }
149
+
150
+ return { severity: 'info', category: 'data_change' }
151
+ }
152
+
153
+ /** Label to show for an audit row that has no resolved actor. */
154
+ export const ANONYMOUS_ACTOR_LABEL = 'Anonymous'
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Single source of truth for "what tables does this app actually have".
3
+ *
4
+ * Three consumers used to answer this question independently — schema generation
5
+ * (scripts/generate-schema.ts), entity route registration
6
+ * (ElysiaPlugin/routes/entity/index.ts) and claim seeding (ElysiaPlugin/index.ts,
7
+ * three call sites) — and they disagreed: the boot/main-schema seeding site let the
8
+ * BUILT-IN columns win while the tenant sites let the CONFIG columns win, so the
9
+ * same config produced different claims in the main schema and in a tenant schema.
10
+ * This function replaces all of that with one rule.
11
+ *
12
+ * PURE: no I/O, no logger, no process access. Errors and warnings are RETURNED, so
13
+ * the caller decides whether to fail boot (runtime) or print and exit (script).
14
+ */
15
+
16
+ /**
17
+ * Minimal structural shape this module reads. Arrays are `readonly` so that both
18
+ * the deeply-`as const` SYSTEM_TABLES tuple and a mutable NucleusTable[] satisfy it.
19
+ */
20
+ export interface ExtendableColumnLike {
21
+ name: string
22
+ notNull?: boolean
23
+ nullable?: boolean
24
+ default?: unknown
25
+ defaultRaw?: string
26
+ generatedAlwaysAs?: string
27
+ generatedAlwaysAsIdentity?: boolean
28
+ generatedByDefaultAsIdentity?: boolean
29
+ sensitive?: boolean
30
+ }
31
+
32
+ export interface ExtendableTableLike {
33
+ table_name: string
34
+ extends?: boolean
35
+ add_base_columns?: boolean
36
+ feature_set?: readonly string[]
37
+ columns?: readonly ExtendableColumnLike[]
38
+ indexes?: readonly unknown[]
39
+ }
40
+
41
+ export type ExtendErrorCode =
42
+ | 'E1_COLUMN_COLLISION'
43
+ | 'E2_NOT_NULL_WITHOUT_DEFAULT'
44
+ | 'E3_CONSUMER_AUTH_TABLE'
45
+ | 'E4_UNKNOWN_SYSTEM_TABLE'
46
+ | 'E5_FEATURE_DISABLED'
47
+
48
+ export interface ExtendError {
49
+ code: ExtendErrorCode
50
+ table: string
51
+ column?: string
52
+ message: string
53
+ }
54
+
55
+ export interface ResolveEntitiesOptions {
56
+ /** Base column names added when add_base_columns is not false. Enables the base half of E1. */
57
+ baseColumnNames?: readonly string[]
58
+ /** True when authentication.mode === 'consumer'. Enables E3. */
59
+ consumerMode?: boolean
60
+ /** System table names that survived feature gating. When given, enables E5. */
61
+ enabledTableNames?: ReadonlySet<string>
62
+ }
63
+
64
+ export interface ResolveEntitiesResult<T> {
65
+ entities: T[]
66
+ errors: ExtendError[]
67
+ warnings: string[]
68
+ }
69
+
70
+ /** Fields an extending entry may declare. Anything else is inherited and warned about. */
71
+ const EXTEND_ALLOWED_KEYS = new Set(['table_name', 'extends', 'columns', 'indexes'])
72
+
73
+ /** A column can satisfy notNull without a client-supplied value only via a DB-side default. */
74
+ function hasDbDefault(column: ExtendableColumnLike): boolean {
75
+ return (
76
+ column.default !== undefined ||
77
+ column.defaultRaw !== undefined ||
78
+ column.generatedAlwaysAs !== undefined ||
79
+ column.generatedAlwaysAsIdentity === true ||
80
+ column.generatedByDefaultAsIdentity === true
81
+ )
82
+ }
83
+
84
+ const AUTH_FEATURES = new Set(['authentication', 'authorization'])
85
+
86
+ export function resolveEntities<T extends ExtendableTableLike>(
87
+ systemTables: readonly T[],
88
+ configEntities: readonly T[],
89
+ options: ResolveEntitiesOptions = {}
90
+ ): ResolveEntitiesResult<T> {
91
+ const errors: ExtendError[] = []
92
+ const warnings: string[] = []
93
+ const consumed = new Set<string>()
94
+ const entities: T[] = []
95
+
96
+ for (const system of systemTables) {
97
+ const config = configEntities.find((c) => c.table_name === system.table_name)
98
+ if (!config) {
99
+ entities.push(system)
100
+ continue
101
+ }
102
+ consumed.add(config.table_name)
103
+
104
+ if (!config.extends) {
105
+ warnings.push(
106
+ `[Schema] "${config.table_name}" is declared in config.entities AND ships as a system table. ` +
107
+ 'Using YOUR definition and skipping the built-in one. Add "extends": true to inherit the ' +
108
+ 'built-in columns and route policy instead.'
109
+ )
110
+ entities.push(config)
111
+ continue
112
+ }
113
+
114
+ for (const key of Object.keys(config)) {
115
+ if (!EXTEND_ALLOWED_KEYS.has(key)) {
116
+ warnings.push(
117
+ `[Schema] "${config.table_name}" sets "${key}" alongside "extends": true — ignored. ` +
118
+ 'An extending entry inherits every table-level setting from the built-in definition.'
119
+ )
120
+ }
121
+ }
122
+
123
+ if (options.enabledTableNames && !options.enabledTableNames.has(system.table_name)) {
124
+ errors.push({
125
+ code: 'E5_FEATURE_DISABLED',
126
+ table: system.table_name,
127
+ message:
128
+ `"${system.table_name}" is extended but its feature is disabled, so the table is never ` +
129
+ 'created. Enable the feature or remove the extending entry.',
130
+ })
131
+ }
132
+
133
+ if (options.consumerMode && (system.feature_set ?? []).some((f) => AUTH_FEATURES.has(f))) {
134
+ errors.push({
135
+ code: 'E3_CONSUMER_AUTH_TABLE',
136
+ table: system.table_name,
137
+ message:
138
+ `"${system.table_name}" is an auth system table and cannot be extended in consumer mode — ` +
139
+ 'the identity tables live in the IDP. Extending it would create a local copy here.',
140
+ })
141
+ }
142
+
143
+ const builtInNames = new Set((system.columns ?? []).map((c) => c.name))
144
+ if (system.add_base_columns !== false) {
145
+ for (const name of options.baseColumnNames ?? []) builtInNames.add(name)
146
+ }
147
+
148
+ for (const column of config.columns ?? []) {
149
+ if (builtInNames.has(column.name)) {
150
+ errors.push({
151
+ code: 'E1_COLUMN_COLLISION',
152
+ table: system.table_name,
153
+ column: column.name,
154
+ message:
155
+ `"${system.table_name}.${column.name}" already exists in the built-in definition. ` +
156
+ 'An extending entry may only ADD columns; rename yours.',
157
+ })
158
+ }
159
+ if (column.notNull === true && column.nullable !== true && !hasDbDefault(column)) {
160
+ errors.push({
161
+ code: 'E2_NOT_NULL_WITHOUT_DEFAULT',
162
+ table: system.table_name,
163
+ column: column.name,
164
+ message:
165
+ `"${system.table_name}.${column.name}" is notNull without a default. The framework writes ` +
166
+ 'this table from fixed-column inserts (register, invite, admin create-user, godmin seed, ' +
167
+ 'tenant provisioning) that cannot supply it — every one of them would fail. Make it ' +
168
+ 'nullable or give it a default.',
169
+ })
170
+ }
171
+ }
172
+
173
+ const merged = {
174
+ ...system,
175
+ columns: [...(system.columns ?? []), ...(config.columns ?? [])],
176
+ indexes: [...(system.indexes ?? []), ...(config.indexes ?? [])],
177
+ } as unknown as T
178
+ entities.push(merged)
179
+ }
180
+
181
+ for (const config of configEntities) {
182
+ if (consumed.has(config.table_name)) continue
183
+ if (config.extends) {
184
+ errors.push({
185
+ code: 'E4_UNKNOWN_SYSTEM_TABLE',
186
+ table: config.table_name,
187
+ message:
188
+ `"${config.table_name}" sets "extends": true but there is no system table with that name. ` +
189
+ 'Remove the flag to declare it as an ordinary entity.',
190
+ })
191
+ continue
192
+ }
193
+ entities.push(config)
194
+ }
195
+
196
+ return { entities, errors, warnings }
197
+ }