supa-forge 0.1.1
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/README.md +65 -0
- package/bin/cli.js +116 -0
- package/dist/index.d.ts +210 -0
- package/dist/index.js +79 -0
- package/dist/index.mjs +1364 -0
- package/dist/typeorm.d.ts +26 -0
- package/dist/typeorm.js +1 -0
- package/dist/typeorm.mjs +15 -0
- package/examples/Only CLS/package-lock.json +3140 -0
- package/examples/Only CLS/package.json +25 -0
- package/examples/Only CLS/src/employee.test.ts +109 -0
- package/examples/Only CLS/src/entities/Employee.entity.ts +27 -0
- package/examples/Only CLS/src/index.ts +45 -0
- package/examples/Only CLS/tsconfig.json +17 -0
- package/examples/Only CLS/vite.config.ts +8 -0
- package/examples/Only RLS/package-lock.json +3140 -0
- package/examples/Only RLS/package.json +25 -0
- package/examples/Only RLS/src/entities/Note.entity.ts +40 -0
- package/examples/Only RLS/src/entities/User.entity.ts +29 -0
- package/examples/Only RLS/src/index.ts +51 -0
- package/examples/Only RLS/src/notes.test.ts +123 -0
- package/examples/Only RLS/tsconfig.json +17 -0
- package/examples/Only RLS/vite.config.ts +8 -0
- package/examples/RBAC + CLS + RLS/package-lock.json +3140 -0
- package/examples/RBAC + CLS + RLS/package.json +21 -0
- package/examples/RBAC + CLS + RLS/src/entities/Project.entity.ts +47 -0
- package/examples/RBAC + CLS + RLS/src/entities/User.entity.ts +31 -0
- package/examples/RBAC + CLS + RLS/src/entities/UserRole.entity.ts +110 -0
- package/examples/RBAC + CLS + RLS/src/index.ts +42 -0
- package/examples/RBAC + CLS + RLS/src/project.test.ts +117 -0
- package/examples/RBAC + CLS + RLS/tsconfig.json +17 -0
- package/examples/RBAC + CLS + RLS/vite.config.ts +8 -0
- package/examples/Without Security/package-lock.json +3940 -0
- package/examples/Without Security/package.json +25 -0
- package/examples/Without Security/src/entities/Pharmacy.entity.ts +20 -0
- package/examples/Without Security/src/index.ts +52 -0
- package/examples/Without Security/src/pharmacy.test.ts +104 -0
- package/examples/Without Security/tsconfig.json +17 -0
- package/examples/Without Security/vite.config.ts +8 -0
- package/package.json +72 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "example-full-security",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"generate": "vite-node src/index.ts --generate",
|
|
7
|
+
"apply": "vite-node src/index.ts --apply",
|
|
8
|
+
"test": "vitest run"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"supaforge": "file:../.."
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/node": "^20.0.0",
|
|
15
|
+
"typeorm": "^0.3.17",
|
|
16
|
+
"typescript": "^5.0.0",
|
|
17
|
+
"vite": "^5.0.0",
|
|
18
|
+
"vite-node": "^1.0.0",
|
|
19
|
+
"vitest": "0.34.6"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'supaforge/typeorm';
|
|
3
|
+
import { security, rls, cls, runSql } from 'supaforge';
|
|
4
|
+
import { UserView } from './User.entity';
|
|
5
|
+
|
|
6
|
+
@Entity('projects')
|
|
7
|
+
@security()
|
|
8
|
+
export class Project {
|
|
9
|
+
@PrimaryGeneratedColumn('uuid')
|
|
10
|
+
@cls({ admin: 'r', manager: 'r', employee: 'r' })
|
|
11
|
+
id!: string;
|
|
12
|
+
|
|
13
|
+
@Column({ type: 'text' })
|
|
14
|
+
@cls({ admin: 'r', manager: 'r', employee: 'r' })
|
|
15
|
+
name!: string;
|
|
16
|
+
|
|
17
|
+
@Column({ type: 'text' })
|
|
18
|
+
@cls({ admin: 'r', manager: 'r', employee: 'r' })
|
|
19
|
+
description!: string;
|
|
20
|
+
|
|
21
|
+
@Column({ type: 'numeric', nullable: true })
|
|
22
|
+
@cls({ admin: 'r', manager: 'r' })
|
|
23
|
+
budget!: number;
|
|
24
|
+
|
|
25
|
+
@Column({ type: 'uuid' })
|
|
26
|
+
@cls({ admin: 'r', manager: 'r', employee: 'r' })
|
|
27
|
+
manager_id!: string;
|
|
28
|
+
|
|
29
|
+
@ManyToOne(() => UserView)
|
|
30
|
+
@JoinColumn({ name: 'manager_id' })
|
|
31
|
+
manager?: UserView;
|
|
32
|
+
|
|
33
|
+
@rls('crud')
|
|
34
|
+
static adminAccess() {
|
|
35
|
+
return { role: 'admin', expression: 'true' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@rls('crud')
|
|
39
|
+
static managerAccess() {
|
|
40
|
+
return { role: 'manager', expression: 'auth.uid()::text = manager_id::text' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@rls('r')
|
|
44
|
+
static employeeAccess() {
|
|
45
|
+
return { role: 'employee', expression: 'true' };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ViewEntity, ViewColumn } from 'supaforge/typeorm';
|
|
2
|
+
|
|
3
|
+
@ViewEntity({
|
|
4
|
+
name: 'auth_users',
|
|
5
|
+
schema: 'public',
|
|
6
|
+
expression: `
|
|
7
|
+
SELECT
|
|
8
|
+
id,
|
|
9
|
+
email,
|
|
10
|
+
raw_user_meta_data,
|
|
11
|
+
created_at,
|
|
12
|
+
last_sign_in_at
|
|
13
|
+
FROM auth.users
|
|
14
|
+
`
|
|
15
|
+
})
|
|
16
|
+
export class UserView {
|
|
17
|
+
@ViewColumn()
|
|
18
|
+
id!: string;
|
|
19
|
+
|
|
20
|
+
@ViewColumn()
|
|
21
|
+
email!: string;
|
|
22
|
+
|
|
23
|
+
@ViewColumn()
|
|
24
|
+
raw_user_meta_data!: any;
|
|
25
|
+
|
|
26
|
+
@ViewColumn()
|
|
27
|
+
created_at!: Date;
|
|
28
|
+
|
|
29
|
+
@ViewColumn()
|
|
30
|
+
last_sign_in_at!: Date;
|
|
31
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn } from 'supaforge/typeorm';
|
|
2
|
+
import { security, rls, cls, runSql } from 'supaforge';
|
|
3
|
+
import { UserView } from './User.entity';
|
|
4
|
+
|
|
5
|
+
@Entity({ name: 'user_roles' })
|
|
6
|
+
@security()
|
|
7
|
+
export class UserRole {
|
|
8
|
+
@PrimaryGeneratedColumn('identity', { type: 'bigint' })
|
|
9
|
+
@cls({ admin: 'r' })
|
|
10
|
+
id!: string;
|
|
11
|
+
|
|
12
|
+
@Column('uuid', { name: 'user_id', nullable: false })
|
|
13
|
+
@cls({ admin: 'cru', manager: 'r', employee: 'r' })
|
|
14
|
+
user_id!: string;
|
|
15
|
+
|
|
16
|
+
@OneToOne('UserView')
|
|
17
|
+
@JoinColumn({ name: 'user_id' })
|
|
18
|
+
user?: UserView;
|
|
19
|
+
|
|
20
|
+
@Column('text')
|
|
21
|
+
@cls({ admin: 'cru' })
|
|
22
|
+
role!: string;
|
|
23
|
+
|
|
24
|
+
@rls('r')
|
|
25
|
+
static ownRole() {
|
|
26
|
+
return {
|
|
27
|
+
role: ['authenticated', 'admin', 'manager', 'employee'],
|
|
28
|
+
expression: '((select auth.uid()) = user_id)'
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@rls('cru')
|
|
33
|
+
static adminControl() {
|
|
34
|
+
return {
|
|
35
|
+
role: 'admin',
|
|
36
|
+
expression: 'true'
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@runSql()
|
|
41
|
+
static setupSyncTrigger() {
|
|
42
|
+
return `
|
|
43
|
+
CREATE OR REPLACE FUNCTION public.handle_auth_user_sync()
|
|
44
|
+
RETURNS TRIGGER AS $$
|
|
45
|
+
BEGIN
|
|
46
|
+
-- Default all new users to 'authenticated' which maps to 'employee' or base access.
|
|
47
|
+
-- In FullSecurity, roles are usually assigned manually by admins.
|
|
48
|
+
INSERT INTO public.user_roles (user_id, role)
|
|
49
|
+
VALUES (new.id, 'authenticated')
|
|
50
|
+
ON CONFLICT (user_id) DO NOTHING;
|
|
51
|
+
RETURN new;
|
|
52
|
+
END;
|
|
53
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '';
|
|
54
|
+
|
|
55
|
+
DROP TRIGGER IF EXISTS on_auth_user_sync_user_roles ON auth.users;
|
|
56
|
+
CREATE TRIGGER on_auth_user_sync_user_roles
|
|
57
|
+
AFTER INSERT OR UPDATE ON auth.users
|
|
58
|
+
FOR EACH ROW EXECUTE PROCEDURE public.handle_auth_user_sync();
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@runSql()
|
|
63
|
+
static setupAuthHook() {
|
|
64
|
+
return `
|
|
65
|
+
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
|
|
66
|
+
RETURNS jsonb
|
|
67
|
+
LANGUAGE plpgsql
|
|
68
|
+
STABLE
|
|
69
|
+
SECURITY DEFINER
|
|
70
|
+
AS $$
|
|
71
|
+
DECLARE
|
|
72
|
+
claims jsonb;
|
|
73
|
+
user_roles_arr jsonb;
|
|
74
|
+
primary_role text;
|
|
75
|
+
BEGIN
|
|
76
|
+
-- Fetch roles from user_roles table
|
|
77
|
+
SELECT jsonb_agg(role) INTO user_roles_arr FROM public.user_roles WHERE user_id = (event->>'user_id')::uuid;
|
|
78
|
+
SELECT role INTO primary_role FROM public.user_roles WHERE user_id = (event->>'user_id')::uuid LIMIT 1;
|
|
79
|
+
claims := event->'claims';
|
|
80
|
+
|
|
81
|
+
IF user_roles_arr IS NOT NULL THEN
|
|
82
|
+
claims := jsonb_set(claims, '{user_roles}', user_roles_arr);
|
|
83
|
+
claims := jsonb_set(claims, '{user_role}', to_jsonb(primary_role));
|
|
84
|
+
ELSE
|
|
85
|
+
claims := jsonb_set(claims, '{user_roles}', '[]'::jsonb);
|
|
86
|
+
claims := jsonb_set(claims, '{user_role}', 'null');
|
|
87
|
+
END IF;
|
|
88
|
+
event := jsonb_set(event, '{claims}', claims);
|
|
89
|
+
RETURN event;
|
|
90
|
+
END;
|
|
91
|
+
$$;
|
|
92
|
+
|
|
93
|
+
GRANT USAGE ON SCHEMA public TO supabase_auth_admin;
|
|
94
|
+
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook TO supabase_auth_admin;
|
|
95
|
+
REVOKE EXECUTE ON FUNCTION public.custom_access_token_hook FROM authenticated, anon, public;
|
|
96
|
+
|
|
97
|
+
GRANT SELECT ON TABLE public.user_roles TO supabase_auth_admin;
|
|
98
|
+
|
|
99
|
+
DO $$
|
|
100
|
+
BEGIN
|
|
101
|
+
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'user_roles') THEN
|
|
102
|
+
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
|
|
103
|
+
DROP POLICY IF EXISTS "Allow auth admin to read roles" ON public.user_roles;
|
|
104
|
+
CREATE POLICY "Allow auth admin to read roles" ON public.user_roles
|
|
105
|
+
AS PERMISSIVE FOR SELECT TO supabase_auth_admin USING (true);
|
|
106
|
+
END IF;
|
|
107
|
+
END $$;
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import 'dotenv/config';
|
|
4
|
+
import { runSupaforge, applyGeneratedMigrations, extractCliOptions, RunnerOptions } from 'supaforge';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
const flags = process.argv;
|
|
12
|
+
const cliOptions = extractCliOptions(flags);
|
|
13
|
+
|
|
14
|
+
const options: RunnerOptions = {
|
|
15
|
+
...cliOptions,
|
|
16
|
+
dbConfig: {
|
|
17
|
+
host: process.env.DB_HOST || 'localhost',
|
|
18
|
+
port: Number(process.env.DB_PORT) || 5432,
|
|
19
|
+
user: process.env.DB_USER || 'postgres',
|
|
20
|
+
password: process.env.DB_PASSWORD || 'postgres',
|
|
21
|
+
database: flags.includes('--temp') ? 'temp_full' : (process.env.DB_NAME || 'postgres'),
|
|
22
|
+
},
|
|
23
|
+
entityPath: path.join(__dirname, 'entities'),
|
|
24
|
+
outputDir: path.join(process.cwd(), 'migrations'),
|
|
25
|
+
syncSchema: cliOptions.syncSchema || flags.includes('--temp'),
|
|
26
|
+
cls: true,
|
|
27
|
+
rls: true,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
if (flags.includes('--apply')) {
|
|
32
|
+
return await applyGeneratedMigrations(options);
|
|
33
|
+
}
|
|
34
|
+
await runSupaforge(options);
|
|
35
|
+
console.log('Full Security Migration generated.');
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error('\nError:', error);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
main();
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
3
|
+
import 'dotenv/config';
|
|
4
|
+
import { DataSource, QueryRunner } from 'supaforge/typeorm';
|
|
5
|
+
import { runSupaforge, applyGeneratedMigrations, runAsRole } from 'supaforge';
|
|
6
|
+
import { Project } from './entities/Project.entity';
|
|
7
|
+
import { UserView } from './entities/User.entity';
|
|
8
|
+
import { UserRole } from './entities/UserRole.entity';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import process from 'process';
|
|
13
|
+
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = path.dirname(__filename);
|
|
16
|
+
|
|
17
|
+
describe('Full Security (RLS+CLS+RBAC) Tests', () => {
|
|
18
|
+
let dataSource: DataSource;
|
|
19
|
+
let queryRunner: QueryRunner;
|
|
20
|
+
|
|
21
|
+
const DB_CONFIG = {
|
|
22
|
+
host: process.env.DB_HOST || 'localhost',
|
|
23
|
+
port: Number(process.env.DB_PORT) || 5432,
|
|
24
|
+
user: process.env.DB_USER || 'postgres',
|
|
25
|
+
password: process.env.DB_PASSWORD || 'postgres',
|
|
26
|
+
database: process.env.DB_NAME || 'postgres',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const USER_MANAGER_A = '10000000-0000-0000-0000-000000000001';
|
|
30
|
+
const USER_MANAGER_B = '20000000-0000-0000-0000-000000000002';
|
|
31
|
+
const USER_EMPLOYEE = '30000000-0000-0000-0000-000000000003';
|
|
32
|
+
const AT_A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
|
33
|
+
const AT_B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
const migrationDir = path.join(process.cwd(), 'migrations');
|
|
37
|
+
if (fs.existsSync(migrationDir)) {
|
|
38
|
+
fs.rmSync(migrationDir, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const options = {
|
|
42
|
+
dbConfig: DB_CONFIG,
|
|
43
|
+
entityPath: path.join(__dirname, 'entities'),
|
|
44
|
+
outputDir: migrationDir,
|
|
45
|
+
syncSchema: true,
|
|
46
|
+
migrationName: 'FullSecurityInit',
|
|
47
|
+
rls: true,
|
|
48
|
+
cls: true
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
await runSupaforge(options);
|
|
52
|
+
await applyGeneratedMigrations(options);
|
|
53
|
+
|
|
54
|
+
dataSource = new DataSource({
|
|
55
|
+
type: 'postgres',
|
|
56
|
+
...DB_CONFIG,
|
|
57
|
+
username: DB_CONFIG.user,
|
|
58
|
+
entities: [Project, UserView, UserRole],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await dataSource.initialize();
|
|
62
|
+
queryRunner = dataSource.createQueryRunner();
|
|
63
|
+
await queryRunner.connect();
|
|
64
|
+
|
|
65
|
+
await queryRunner.query('DROP TRIGGER IF EXISTS on_auth_user_sync_user_roles ON auth.users');
|
|
66
|
+
await queryRunner.query('DROP FUNCTION IF EXISTS public.handle_auth_user_sync() CASCADE');
|
|
67
|
+
await queryRunner.query('DELETE FROM projects');
|
|
68
|
+
await queryRunner.query('DELETE FROM auth.users CASCADE');
|
|
69
|
+
await queryRunner.query("INSERT INTO auth.users (id, email) VALUES ($1, 'managerA@example.com'), ($2, 'managerB@example.com'), ($3, 'employee@example.com')", [USER_MANAGER_A, USER_MANAGER_B, USER_EMPLOYEE]);
|
|
70
|
+
|
|
71
|
+
await queryRunner.query("INSERT INTO projects (id, name, description, budget, manager_id) VALUES ($1, 'Space Mission', 'Very expensive project', 1000000, $2)", [AT_A, USER_MANAGER_A]);
|
|
72
|
+
await queryRunner.query("INSERT INTO projects (id, name, description, budget, manager_id) VALUES ($1, 'Office Renovation', 'Paint the walls', 500, $2)", [AT_B, USER_MANAGER_B]);
|
|
73
|
+
}, 45000);
|
|
74
|
+
|
|
75
|
+
afterAll(async () => {
|
|
76
|
+
if (queryRunner) await queryRunner.release();
|
|
77
|
+
if (dataSource) await dataSource.destroy();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('Admin should see all rows and budget', async () => {
|
|
81
|
+
const results = await runAsRole(queryRunner, 'admin', 'admin-id',
|
|
82
|
+
'SELECT name, budget FROM projects'
|
|
83
|
+
);
|
|
84
|
+
expect(results).toHaveLength(2);
|
|
85
|
+
expect(results.some(r => r.name === 'Space Mission' && r.budget === '1000000')).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('Manager A should ONLY see Space Mission and its budget', async () => {
|
|
89
|
+
const results = await runAsRole(queryRunner, 'manager', USER_MANAGER_A,
|
|
90
|
+
'SELECT name, budget FROM projects'
|
|
91
|
+
);
|
|
92
|
+
expect(results).toHaveLength(1);
|
|
93
|
+
expect(results[0].name).toBe('Space Mission');
|
|
94
|
+
expect(results[0].budget).toBe('1000000');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('Manager B should ONLY see Office Renovation and its budget', async () => {
|
|
98
|
+
const results = await runAsRole(queryRunner, 'manager', USER_MANAGER_B,
|
|
99
|
+
'SELECT name, budget FROM projects'
|
|
100
|
+
);
|
|
101
|
+
expect(results).toHaveLength(1);
|
|
102
|
+
expect(results[0].name).toBe('Office Renovation');
|
|
103
|
+
expect(results[0].budget).toBe('500');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('Employee should see all projects but NOT budget (CLS)', async () => {
|
|
107
|
+
const results = await runAsRole(queryRunner, 'employee', USER_EMPLOYEE,
|
|
108
|
+
'SELECT name, description FROM projects'
|
|
109
|
+
);
|
|
110
|
+
expect(results).toHaveLength(2);
|
|
111
|
+
|
|
112
|
+
const operation = runAsRole(queryRunner, 'employee', USER_EMPLOYEE,
|
|
113
|
+
'SELECT budget FROM projects'
|
|
114
|
+
);
|
|
115
|
+
await expect(operation).rejects.toThrow(/permission denied/);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"experimentalDecorators": true,
|
|
9
|
+
"emitDecoratorMetadata": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"noImplicitAny": true,
|
|
13
|
+
"outDir": "./dist",
|
|
14
|
+
"types": ["node"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*"]
|
|
17
|
+
}
|