@mettlecast/eslint-plugin-domain-module 0.2.59 → 0.2.61
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 +48 -14
- package/dist/__tests__/api-needs-fixture.test.js +11 -7
- package/dist/__tests__/no-raw-aws-sdk.test.js +33 -0
- package/dist/__tests__/no-raw-db-client-release.test.d.ts +1 -0
- package/dist/__tests__/no-raw-db-client-release.test.js +66 -0
- package/dist/__tests__/no-raw-fetch.test.js +6 -6
- package/dist/__tests__/no-raw-http-server.test.js +1 -1
- package/dist/__tests__/no-raw-pg-client.test.d.ts +1 -0
- package/dist/__tests__/no-raw-pg-client.test.js +77 -0
- package/dist/__tests__/prefer-result-over-throw.test.js +11 -11
- package/dist/__tests__/require-define-primitive.test.js +1 -1
- package/dist/index.d.ts +14 -0
- package/dist/index.js +8 -0
- package/dist/rules/api-needs-fixture.js +34 -22
- package/dist/rules/no-raw-aws-sdk.js +101 -4
- package/dist/rules/no-raw-db-client-release.d.ts +4 -0
- package/dist/rules/no-raw-db-client-release.js +88 -0
- package/dist/rules/no-raw-http-server.js +1 -1
- package/dist/rules/no-raw-pg-client.d.ts +4 -0
- package/dist/rules/no-raw-pg-client.js +49 -0
- package/dist/rules/require-define-primitive.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @mettlecast/eslint-plugin-domain-module
|
|
2
2
|
|
|
3
|
-
ESLint plugin enforcing TIB Domain Module framework usage patterns. Prevents accidental AWS SDK, HTTP server, and cross-domain violations.
|
|
3
|
+
ESLint plugin enforcing TIB Domain Module framework usage patterns. Prevents accidental AWS SDK, HTTP server, Postgres client, raw DB lifecycle calls, and cross-domain violations in domain handler files.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -25,22 +25,40 @@ export default [
|
|
|
25
25
|
|
|
26
26
|
| Rule | Flags | Severity |
|
|
27
27
|
|---|---|---|
|
|
28
|
-
| `no-raw-aws-sdk` | Direct imports of `@aws-sdk/*` packages in domain handler files | error |
|
|
28
|
+
| `no-raw-aws-sdk` | Direct imports of `@aws-sdk/*` packages in domain handler files (Lambda, DynamoDB, S3, EventBridge, SQS, SNS, Secrets Manager, STS, Cognito, etc. — each maps to the correct `ctx.*` method) | error |
|
|
29
|
+
| `no-raw-pg-client` | Direct `pg`, `pg-pool`, `pg-cursor`, `postgres`, `pg-protocol`, `pg-query-stream` imports — use `ctx.db` | error |
|
|
30
|
+
| `no-raw-db-client-release` | Low-level `client.release()` / `pool.end()` / `pgClient.release()` / `poolClient.release()` calls — runtime owns pool lifecycle | error |
|
|
29
31
|
| `no-raw-http-server` | Framework imports (`express`, `koa`, `fastify`, `aws-lambda`) in domain handlers | error |
|
|
32
|
+
| `no-raw-fetch` | Raw `fetch()` calls in domain handlers — use `ctx.fetch` | error |
|
|
30
33
|
| `require-define-primitive` | Handler files without a top-level `define*()` call (missing domain primitive declaration) | warn |
|
|
31
34
|
| `no-cross-domain-internal-import` | Importing internal modules (not exported types) from another domain directory | error |
|
|
35
|
+
| `flow-domain-ownership` | Flow files in `domains/{x}/flows/` must declare `owningDomain: 'x'` | error |
|
|
36
|
+
| `zod-defaults-required` | Top-level Zod input/output schemas must end in `.default(...)` | error |
|
|
37
|
+
| `api-needs-fixture` | Every API-exposed `defineAction` needs a sibling `__tests__/<id>.fixture.json` | error |
|
|
38
|
+
| `prefer-result-over-throw` | Domain handlers must `return err({...})` instead of throwing | error |
|
|
39
|
+
| `use-tanstack-router` | Frontend must use TanStack Router | error |
|
|
40
|
+
| `tanstack-query-options` | TanStack Query data must be wrapped in `queryOptions(...)` | warn |
|
|
32
41
|
|
|
33
42
|
## Recommended Config
|
|
34
43
|
|
|
35
|
-
The `recommended` preset enables all
|
|
44
|
+
The `recommended` preset enables all rules at their default severity levels:
|
|
36
45
|
|
|
37
46
|
```javascript
|
|
38
47
|
domainModulePlugin.configs.recommended
|
|
39
48
|
// Results in:
|
|
40
49
|
// - no-raw-aws-sdk: error
|
|
50
|
+
// - no-raw-pg-client: error
|
|
51
|
+
// - no-raw-db-client-release: error
|
|
41
52
|
// - no-raw-http-server: error
|
|
53
|
+
// - no-raw-fetch: error
|
|
42
54
|
// - require-define-primitive: warn
|
|
43
55
|
// - no-cross-domain-internal-import: error
|
|
56
|
+
// - flow-domain-ownership: error
|
|
57
|
+
// - zod-defaults-required: error
|
|
58
|
+
// - api-needs-fixture: error
|
|
59
|
+
// - prefer-result-over-throw: error
|
|
60
|
+
// - use-tanstack-router: error
|
|
61
|
+
// - tanstack-query-options: warn
|
|
44
62
|
```
|
|
45
63
|
|
|
46
64
|
## Examples
|
|
@@ -48,9 +66,22 @@ domainModulePlugin.configs.recommended
|
|
|
48
66
|
**Violations caught:**
|
|
49
67
|
|
|
50
68
|
```typescript
|
|
51
|
-
// ❌ no-raw-aws-sdk
|
|
69
|
+
// ❌ no-raw-aws-sdk (Lambda) — use ctx.actions.call(...)
|
|
70
|
+
import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
|
|
71
|
+
|
|
72
|
+
// ❌ no-raw-aws-sdk (DynamoDB) — use ctx.store.{get,put,query,...}
|
|
52
73
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
53
74
|
|
|
75
|
+
// ❌ no-raw-aws-sdk (S3) — use ctx.files.{get,put,...}
|
|
76
|
+
import { S3Client } from '@aws-sdk/client-s3';
|
|
77
|
+
|
|
78
|
+
// ❌ no-raw-pg-client — use ctx.db.query(...) / ctx.db.client.query(...)
|
|
79
|
+
import { Pool } from 'pg';
|
|
80
|
+
|
|
81
|
+
// ❌ no-raw-db-client-release — runtime owns pool lifecycle
|
|
82
|
+
pool.end();
|
|
83
|
+
client.release();
|
|
84
|
+
|
|
54
85
|
// ❌ no-raw-http-server
|
|
55
86
|
import express from 'express';
|
|
56
87
|
|
|
@@ -64,20 +95,23 @@ import { internalFn } from '../payments/internal.js'; // not in payments/index.t
|
|
|
64
95
|
**Correct usage:**
|
|
65
96
|
|
|
66
97
|
```typescript
|
|
67
|
-
// ✓
|
|
68
|
-
|
|
98
|
+
// ✓ Backend-to-backend calls go through the action envelope
|
|
99
|
+
await ctx.actions.call('payments.charge-card', { amount });
|
|
100
|
+
|
|
101
|
+
// ✓ Per-tenant DynamoDB
|
|
102
|
+
await ctx.store.put('user#123', { email: 'a@example.com' });
|
|
103
|
+
|
|
104
|
+
// ✓ Per-tenant S3
|
|
105
|
+
await ctx.files.put('reports/q4.pdf', pdfBuffer);
|
|
69
106
|
|
|
70
|
-
// ✓
|
|
71
|
-
|
|
72
|
-
path: '/charge',
|
|
73
|
-
// ...
|
|
74
|
-
});
|
|
107
|
+
// ✓ DB queries always carry Wave 5 session variables
|
|
108
|
+
await ctx.db.query('SELECT * FROM orders WHERE tenant = $1', [ctx.tenant.id]);
|
|
75
109
|
|
|
76
|
-
// ✓
|
|
77
|
-
|
|
110
|
+
// ✓ Runtime releases the pool for you — do not call .release() / .end()
|
|
111
|
+
await ctx.db.release(); // only if you really need to release early
|
|
78
112
|
```
|
|
79
113
|
|
|
80
114
|
## See Also
|
|
81
115
|
|
|
82
|
-
- `@mettlecast/domain-runtime` — handler definitions
|
|
116
|
+
- `@mettlecast/domain-runtime` — handler definitions and `DomainContext`
|
|
83
117
|
- `@mettlecast/domain-cli` — validation and linting
|
|
@@ -14,10 +14,10 @@ RuleTester.describe = describe;
|
|
|
14
14
|
RuleTester.it = it;
|
|
15
15
|
const existsSyncMock = vi.mocked(existsSync);
|
|
16
16
|
const FIXTURE_DIRS_WITH_FIXTURE = new Set([
|
|
17
|
-
'/repo/domains/auth/
|
|
17
|
+
'/repo/domains/auth/actions/__tests__/get-user.fixture.json',
|
|
18
18
|
]);
|
|
19
19
|
const FIXTURE_DIRS_WITHOUT_FIXTURE = new Set([
|
|
20
|
-
'/repo/domains/payments/
|
|
20
|
+
'/repo/domains/payments/actions/__tests__/charge-card.fixture.json',
|
|
21
21
|
]);
|
|
22
22
|
beforeEach(() => {
|
|
23
23
|
existsSyncMock.mockReset();
|
|
@@ -39,22 +39,26 @@ const tester = new RuleTester({
|
|
|
39
39
|
tester.run('api-needs-fixture', apiNeedsFixture, {
|
|
40
40
|
valid: [
|
|
41
41
|
{
|
|
42
|
-
filename: '/repo/domains/auth/
|
|
43
|
-
code: "import {
|
|
42
|
+
filename: '/repo/domains/auth/actions/get-user.ts',
|
|
43
|
+
code: "import { defineAction } from '@mettlecast/domain-runtime'; export const getUser = defineAction({ id: 'get-user', backendAccess: 'domain', exposure: { type: 'api', path: '/v1/users/{id}', method: 'GET', auth: 'required', tenancy: 'required' }, idempotent: true, input: z.object({}), output: z.object({}), handler: async () => ({}) });",
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
filename: '/repo/domains/auth/domain.config.ts',
|
|
47
47
|
code: "export const config = { id: 'auth', name: 'Auth' };",
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
|
-
filename: '/repo/domains/auth/
|
|
50
|
+
filename: '/repo/domains/auth/actions/utils.ts',
|
|
51
51
|
code: "export function helper() { return 1; }",
|
|
52
52
|
},
|
|
53
|
+
{
|
|
54
|
+
filename: '/repo/domains/auth/actions/internal-helper.ts',
|
|
55
|
+
code: "import { defineAction } from '@mettlecast/domain-runtime'; export const helper = defineAction({ id: 'helper', backendAccess: 'private', exposure: { type: 'internal' }, idempotent: true, input: z.object({}), output: z.object({}), handler: async () => ({}) });",
|
|
56
|
+
},
|
|
53
57
|
],
|
|
54
58
|
invalid: [
|
|
55
59
|
{
|
|
56
|
-
filename: '/repo/domains/payments/
|
|
57
|
-
code: "import {
|
|
60
|
+
filename: '/repo/domains/payments/actions/charge-card.ts',
|
|
61
|
+
code: "import { defineAction } from '@mettlecast/domain-runtime'; export const chargeCard = defineAction({ id: 'charge-card', backendAccess: 'domain', exposure: { type: 'api', path: '/v1/charge', method: 'POST', auth: 'required', tenancy: 'required' }, idempotent: false, input: z.object({}), output: z.object({}), handler: async () => ({}) });",
|
|
58
62
|
errors: [{ messageId: 'apiNeedsFixture' }],
|
|
59
63
|
},
|
|
60
64
|
],
|
|
@@ -12,6 +12,8 @@ tester.run('no-raw-aws-sdk', noRawAwsSdk, {
|
|
|
12
12
|
{ code: "import { something } from '@my-org/utils';" },
|
|
13
13
|
{ code: "import { DomainContext } from '@mettlecast/domain-runtime';" },
|
|
14
14
|
{ code: "const x = require('fs');" },
|
|
15
|
+
// pg imports are handled by a separate rule — they are valid for no-raw-aws-sdk
|
|
16
|
+
{ code: "import { Pool } from 'pg';" },
|
|
15
17
|
],
|
|
16
18
|
invalid: [
|
|
17
19
|
{
|
|
@@ -26,5 +28,36 @@ tester.run('no-raw-aws-sdk', noRawAwsSdk, {
|
|
|
26
28
|
code: "import { EventBridgeClient } from '@aws-sdk/client-eventbridge';",
|
|
27
29
|
errors: [{ messageId: 'noRawAwsSdk' }],
|
|
28
30
|
},
|
|
31
|
+
// Wave 6 Task 6.2 — additional bypass patterns point at the right ctx method.
|
|
32
|
+
{
|
|
33
|
+
code: "import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';",
|
|
34
|
+
errors: [{ messageId: 'noRawAwsSdk' }],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
code: "import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';",
|
|
38
|
+
errors: [{ messageId: 'noRawAwsSdk' }],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
code: "import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';",
|
|
42
|
+
errors: [{ messageId: 'noRawAwsSdk' }],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
code: "import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';",
|
|
46
|
+
errors: [{ messageId: 'noRawAwsSdk' }],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
code: "import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager';",
|
|
50
|
+
errors: [{ messageId: 'noRawAwsSdk' }],
|
|
51
|
+
},
|
|
52
|
+
// Subpath imports still match (substring matcher).
|
|
53
|
+
{
|
|
54
|
+
code: "import { Foo } from '@aws-sdk/client-dynamodb/dist-cjs/commands/GetItemCommand.js';",
|
|
55
|
+
errors: [{ messageId: 'noRawAwsSdk' }],
|
|
56
|
+
},
|
|
57
|
+
// Unknown @aws-sdk/* packages still fall through to the generic message.
|
|
58
|
+
{
|
|
59
|
+
code: "import { Foo } from '@aws-sdk/client-some-new-service';",
|
|
60
|
+
errors: [{ messageId: 'noRawAwsSdk' }],
|
|
61
|
+
},
|
|
29
62
|
],
|
|
30
63
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
import { afterAll, describe, it } from 'vitest';
|
|
3
|
+
import { noRawDbClientRelease } from '../rules/no-raw-db-client-release.js';
|
|
4
|
+
RuleTester.afterAll = afterAll;
|
|
5
|
+
RuleTester.describe = describe;
|
|
6
|
+
RuleTester.it = it;
|
|
7
|
+
const tester = new RuleTester({
|
|
8
|
+
languageOptions: { ecmaVersion: 2022, sourceType: 'module' },
|
|
9
|
+
});
|
|
10
|
+
tester.run('no-raw-db-client-release', noRawDbClientRelease, {
|
|
11
|
+
valid: [
|
|
12
|
+
// Runtime-managed release through ctx.db is the supported pattern.
|
|
13
|
+
"await ctx.db.release();",
|
|
14
|
+
"await ctx.db.release(true);",
|
|
15
|
+
// Unknown method names on otherwise innocuous objects.
|
|
16
|
+
"client.close();",
|
|
17
|
+
"client.destroy();",
|
|
18
|
+
"pool.disconnect();",
|
|
19
|
+
// release()/end() on unrelated objects (object names not in hints).
|
|
20
|
+
"thing.release();",
|
|
21
|
+
"thing.end();",
|
|
22
|
+
"user.release();",
|
|
23
|
+
// Property is not `release`/`end` even if object matches hint.
|
|
24
|
+
"client.query();",
|
|
25
|
+
"pool.connect();",
|
|
26
|
+
// Nested calls that don't end in release()/end().
|
|
27
|
+
"client.query('SELECT release()');",
|
|
28
|
+
"client.endOfFile;",
|
|
29
|
+
"pool.endOfStream();",
|
|
30
|
+
],
|
|
31
|
+
invalid: [
|
|
32
|
+
// The most common bypass: db.client.release().
|
|
33
|
+
{
|
|
34
|
+
code: 'db.client.release();',
|
|
35
|
+
errors: [{ messageId: 'noRawDbClientRelease', data: { call: 'db.client.release' } }],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
code: 'await ctx.db.client.release();',
|
|
39
|
+
errors: [{ messageId: 'noRawDbClientRelease', data: { call: 'ctx.db.client.release' } }],
|
|
40
|
+
},
|
|
41
|
+
// Direct client.release().
|
|
42
|
+
{
|
|
43
|
+
code: 'client.release();',
|
|
44
|
+
errors: [{ messageId: 'noRawDbClientRelease', data: { call: 'client.release' } }],
|
|
45
|
+
},
|
|
46
|
+
// pool.end() / poolClient.release().
|
|
47
|
+
{
|
|
48
|
+
code: 'pool.end();',
|
|
49
|
+
errors: [{ messageId: 'noRawDbClientRelease', data: { call: 'pool.end' } }],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
code: 'poolClient.release();',
|
|
53
|
+
errors: [{ messageId: 'noRawDbClientRelease', data: { call: 'poolClient.release' } }],
|
|
54
|
+
},
|
|
55
|
+
// pgClient is also a recognised hint name.
|
|
56
|
+
{
|
|
57
|
+
code: 'pgClient.release(true);',
|
|
58
|
+
errors: [{ messageId: 'noRawDbClientRelease', data: { call: 'pgClient.release' } }],
|
|
59
|
+
},
|
|
60
|
+
// Inside a function body — still caught.
|
|
61
|
+
{
|
|
62
|
+
code: 'function shutdown() { pool.end(); }',
|
|
63
|
+
errors: [{ messageId: 'noRawDbClientRelease', data: { call: 'pool.end' } }],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
});
|
|
@@ -10,26 +10,26 @@ const tester = new RuleTester({
|
|
|
10
10
|
tester.run('no-raw-fetch', noRawFetch, {
|
|
11
11
|
valid: [
|
|
12
12
|
{
|
|
13
|
-
filename: '/repo/domains/auth/
|
|
14
|
-
code: "export const get =
|
|
13
|
+
filename: '/repo/domains/auth/actions/get-user.ts',
|
|
14
|
+
code: "export const get = defineAction({ handler: async (input, ctx) => { return await ctx.fetch('https://example.com'); } });",
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
|
-
filename: '/repo/domains/auth/
|
|
17
|
+
filename: '/repo/domains/auth/actions/get-user.ts',
|
|
18
18
|
code: "import ky from 'ky'; const r = await ky.get('https://example.com');",
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
|
-
filename: '/repo/domains/auth/
|
|
21
|
+
filename: '/repo/domains/auth/actions/get-user.ts',
|
|
22
22
|
code: "const r = await globalThis.fetch('https://example.com');",
|
|
23
23
|
},
|
|
24
24
|
],
|
|
25
25
|
invalid: [
|
|
26
26
|
{
|
|
27
|
-
filename: '/repo/domains/auth/
|
|
27
|
+
filename: '/repo/domains/auth/actions/get-user.ts',
|
|
28
28
|
code: "const r = await fetch('https://example.com');",
|
|
29
29
|
errors: [{ messageId: 'noRawFetch' }],
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
|
-
filename: '/repo/domains/payments/
|
|
32
|
+
filename: '/repo/domains/payments/actions/charge.ts',
|
|
33
33
|
code: "fetch('https://api.stripe.com/v1/charges', { method: 'POST' });",
|
|
34
34
|
errors: [{ messageId: 'noRawFetch' }],
|
|
35
35
|
},
|
|
@@ -9,7 +9,7 @@ const tester = new RuleTester({
|
|
|
9
9
|
});
|
|
10
10
|
tester.run('no-raw-http-server', noRawHttpServer, {
|
|
11
11
|
valid: [
|
|
12
|
-
{ code: "import {
|
|
12
|
+
{ code: "import { defineAction } from '@mettlecast/domain-runtime';" },
|
|
13
13
|
{ code: "import path from 'node:path';" },
|
|
14
14
|
],
|
|
15
15
|
invalid: [
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
import { afterAll, describe, it } from 'vitest';
|
|
3
|
+
import { noRawPgClient } from '../rules/no-raw-pg-client.js';
|
|
4
|
+
RuleTester.afterAll = afterAll;
|
|
5
|
+
RuleTester.describe = describe;
|
|
6
|
+
RuleTester.it = it;
|
|
7
|
+
const tester = new RuleTester({
|
|
8
|
+
languageOptions: { ecmaVersion: 2022, sourceType: 'module' },
|
|
9
|
+
});
|
|
10
|
+
tester.run('no-raw-pg-client', noRawPgClient, {
|
|
11
|
+
valid: [
|
|
12
|
+
// Domain code that uses ctx.db is fine.
|
|
13
|
+
{ code: "import { defineAction } from '@mettlecast/domain-runtime';" },
|
|
14
|
+
{ code: "import { drizzle } from 'drizzle-orm/node-postgres';" },
|
|
15
|
+
// Other packages that aren't in the ban-list stay allowed.
|
|
16
|
+
{ code: "import { something } from 'pg-query-emscripten';" },
|
|
17
|
+
// Subpath that isn't actually a pg client.
|
|
18
|
+
{ code: "import { bar } from 'pg-mock-something-else';" },
|
|
19
|
+
],
|
|
20
|
+
invalid: [
|
|
21
|
+
{
|
|
22
|
+
code: "import { Pool } from 'pg';",
|
|
23
|
+
errors: [
|
|
24
|
+
{
|
|
25
|
+
messageId: 'noRawPgClient',
|
|
26
|
+
data: { source: 'pg', label: 'node-postgres (pg)' },
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
code: "import { Pool } from 'pg-pool';",
|
|
32
|
+
errors: [
|
|
33
|
+
{
|
|
34
|
+
messageId: 'noRawPgClient',
|
|
35
|
+
data: { source: 'pg-pool', label: 'pg.Pool' },
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
code: "import { Client } from 'pg-protocol';",
|
|
41
|
+
errors: [
|
|
42
|
+
{
|
|
43
|
+
messageId: 'noRawPgClient',
|
|
44
|
+
data: { source: 'pg-protocol', label: 'pg-protocol' },
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
code: "import { cursor } from 'pg-cursor';",
|
|
50
|
+
errors: [
|
|
51
|
+
{
|
|
52
|
+
messageId: 'noRawPgClient',
|
|
53
|
+
data: { source: 'pg-cursor', label: 'pg-cursor' },
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
code: "import postgres from 'postgres';",
|
|
59
|
+
errors: [
|
|
60
|
+
{
|
|
61
|
+
messageId: 'noRawPgClient',
|
|
62
|
+
data: { source: 'postgres', label: 'postgres (porsager/postgres)' },
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
// Subpath imports are still caught.
|
|
67
|
+
{
|
|
68
|
+
code: "import { Pool } from 'pg-pool/sub';",
|
|
69
|
+
errors: [
|
|
70
|
+
{
|
|
71
|
+
messageId: 'noRawPgClient',
|
|
72
|
+
data: { source: 'pg-pool/sub', label: 'pg.Pool' },
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
});
|
|
@@ -10,17 +10,17 @@ const ruleTester = new RuleTester({
|
|
|
10
10
|
ruleTester.run('prefer-result-over-throw', preferResultOverThrow, {
|
|
11
11
|
valid: [
|
|
12
12
|
{
|
|
13
|
-
filename: 'domains/auth/
|
|
13
|
+
filename: 'domains/auth/actions/me.ts',
|
|
14
14
|
code: `
|
|
15
15
|
import { ok, err } from '@mettlecast/domain-runtime';
|
|
16
|
-
export const me =
|
|
16
|
+
export const me = defineAction({
|
|
17
17
|
handler: async (_input, _ctx) => {
|
|
18
18
|
return ok({ id: 'usr_1' });
|
|
19
19
|
},
|
|
20
20
|
});`,
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
|
-
filename: 'domains/auth/
|
|
23
|
+
filename: 'domains/auth/actions/me.ts',
|
|
24
24
|
code: `
|
|
25
25
|
import { redirect } from '@tanstack/react-router';
|
|
26
26
|
// TSR redirect is not a domain error — it's a navigation directive
|
|
@@ -31,10 +31,10 @@ throw redirect({ to: '/dashboard' });`,
|
|
|
31
31
|
code: `throw new Error('Upgrade failed');`,
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
|
-
filename: 'domains/auth/
|
|
34
|
+
filename: 'domains/auth/actions/me.ts',
|
|
35
35
|
code: `
|
|
36
36
|
import { ok, err, notFound } from '@mettlecast/domain-runtime';
|
|
37
|
-
export const me =
|
|
37
|
+
export const me = defineAction({
|
|
38
38
|
handler: async (_input, _ctx) => {
|
|
39
39
|
const user = await ctx.db.query(...);
|
|
40
40
|
if (!user) return notFound('user', input.userId);
|
|
@@ -45,9 +45,9 @@ export const me = defineApi({
|
|
|
45
45
|
],
|
|
46
46
|
invalid: [
|
|
47
47
|
{
|
|
48
|
-
filename: 'domains/auth/
|
|
48
|
+
filename: 'domains/auth/actions/me.ts',
|
|
49
49
|
code: `
|
|
50
|
-
export const me =
|
|
50
|
+
export const me = defineAction({
|
|
51
51
|
handler: async () => {
|
|
52
52
|
throw new Error('User not found');
|
|
53
53
|
},
|
|
@@ -55,9 +55,9 @@ export const me = defineApi({
|
|
|
55
55
|
errors: [{ messageId: 'preferResult' }],
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
|
-
filename: 'domains/orgs/
|
|
58
|
+
filename: 'domains/orgs/actions/list-orgs.ts',
|
|
59
59
|
code: `
|
|
60
|
-
export const listOrgs =
|
|
60
|
+
export const listOrgs = defineAction({
|
|
61
61
|
handler: async (_input, _ctx) => {
|
|
62
62
|
if (!ctx.tenant) throw new Error('Missing tenant');
|
|
63
63
|
return { items: [] };
|
|
@@ -66,9 +66,9 @@ export const listOrgs = defineApi({
|
|
|
66
66
|
errors: [{ messageId: 'preferResult' }],
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
|
-
filename: 'domains/tenants/
|
|
69
|
+
filename: 'domains/tenants/actions/create-tenant.ts',
|
|
70
70
|
code: `
|
|
71
|
-
export const createTenant =
|
|
71
|
+
export const createTenant = defineAction({
|
|
72
72
|
handler: async () => {
|
|
73
73
|
throw { kind: 'not_found' }; // raw object throw — no stack trace
|
|
74
74
|
},
|
|
@@ -9,7 +9,7 @@ const tester = new RuleTester({
|
|
|
9
9
|
});
|
|
10
10
|
tester.run('require-define-primitive', requireDefinePrimitive, {
|
|
11
11
|
valid: [
|
|
12
|
-
{ code: "export const
|
|
12
|
+
{ code: "export const myAction = defineAction({ id: 'test', exposure: { type: 'internal' } });" },
|
|
13
13
|
{ code: "export const sub = defineSubscriber({ id: 'x', event: 'y', semverRange: '^1.0' });" },
|
|
14
14
|
{ code: "const domain = defineDomain({ id: 'my-domain', name: 'My Domain', tenancy: 'single' });" },
|
|
15
15
|
],
|
package/dist/index.d.ts
CHANGED
|
@@ -38,6 +38,12 @@ declare const _default: {
|
|
|
38
38
|
readonly 'prefer-result-over-throw': import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferResult", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
39
39
|
name: string;
|
|
40
40
|
};
|
|
41
|
+
readonly 'no-raw-pg-client': import("@typescript-eslint/utils/ts-eslint").RuleModule<"noRawPgClient", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
42
|
+
name: string;
|
|
43
|
+
};
|
|
44
|
+
readonly 'no-raw-db-client-release': import("@typescript-eslint/utils/ts-eslint").RuleModule<"noRawDbClientRelease", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
45
|
+
name: string;
|
|
46
|
+
};
|
|
41
47
|
};
|
|
42
48
|
configs: {
|
|
43
49
|
recommended: {
|
|
@@ -77,6 +83,12 @@ declare const _default: {
|
|
|
77
83
|
readonly 'prefer-result-over-throw': import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferResult", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
78
84
|
name: string;
|
|
79
85
|
};
|
|
86
|
+
readonly 'no-raw-pg-client': import("@typescript-eslint/utils/ts-eslint").RuleModule<"noRawPgClient", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
87
|
+
name: string;
|
|
88
|
+
};
|
|
89
|
+
readonly 'no-raw-db-client-release': import("@typescript-eslint/utils/ts-eslint").RuleModule<"noRawDbClientRelease", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
90
|
+
name: string;
|
|
91
|
+
};
|
|
80
92
|
};
|
|
81
93
|
};
|
|
82
94
|
};
|
|
@@ -92,6 +104,8 @@ declare const _default: {
|
|
|
92
104
|
readonly '@mettlecast/domain-module/zod-defaults-required': "error";
|
|
93
105
|
readonly '@mettlecast/domain-module/api-needs-fixture': "error";
|
|
94
106
|
readonly '@mettlecast/domain-module/prefer-result-over-throw': "error";
|
|
107
|
+
readonly '@mettlecast/domain-module/no-raw-pg-client': "error";
|
|
108
|
+
readonly '@mettlecast/domain-module/no-raw-db-client-release': "error";
|
|
95
109
|
};
|
|
96
110
|
};
|
|
97
111
|
};
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,8 @@ import { tanstackQueryOptions } from './rules/tanstack-query-options.js';
|
|
|
9
9
|
import { zodDefaultsRequired } from './rules/zod-defaults-required.js';
|
|
10
10
|
import { apiNeedsFixture } from './rules/api-needs-fixture.js';
|
|
11
11
|
import { preferResultOverThrow } from './rules/prefer-result-over-throw.js';
|
|
12
|
+
import { noRawPgClient } from './rules/no-raw-pg-client.js';
|
|
13
|
+
import { noRawDbClientRelease } from './rules/no-raw-db-client-release.js';
|
|
12
14
|
/**
|
|
13
15
|
* All rules exported by @mettlecast/eslint-plugin-domain-module.
|
|
14
16
|
*/
|
|
@@ -24,6 +26,9 @@ const rules = {
|
|
|
24
26
|
'zod-defaults-required': zodDefaultsRequired,
|
|
25
27
|
'api-needs-fixture': apiNeedsFixture,
|
|
26
28
|
'prefer-result-over-throw': preferResultOverThrow,
|
|
29
|
+
// Wave 6 Task 6.2 — auth/tenant bypass prevention
|
|
30
|
+
'no-raw-pg-client': noRawPgClient,
|
|
31
|
+
'no-raw-db-client-release': noRawDbClientRelease,
|
|
27
32
|
};
|
|
28
33
|
/**
|
|
29
34
|
* Recommended flat config preset.
|
|
@@ -45,6 +50,9 @@ const recommended = {
|
|
|
45
50
|
'@mettlecast/domain-module/zod-defaults-required': 'error',
|
|
46
51
|
'@mettlecast/domain-module/api-needs-fixture': 'error',
|
|
47
52
|
'@mettlecast/domain-module/prefer-result-over-throw': 'error',
|
|
53
|
+
// Wave 6 Task 6.2 — auth/tenant bypass prevention
|
|
54
|
+
'@mettlecast/domain-module/no-raw-pg-client': 'error',
|
|
55
|
+
'@mettlecast/domain-module/no-raw-db-client-release': 'error',
|
|
48
56
|
},
|
|
49
57
|
};
|
|
50
58
|
/**
|
|
@@ -2,18 +2,9 @@ import { ESLintUtils } from '@typescript-eslint/utils';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/LawrenceGeeHouse/Mettlecast/blob/develop/packages/eslint-plugin-domain-module/docs/${name}.md`);
|
|
5
|
-
const
|
|
6
|
-
function
|
|
7
|
-
const
|
|
8
|
-
if (!arg || arg.type !== 'ObjectExpression')
|
|
9
|
-
return undefined;
|
|
10
|
-
const idProp = arg.properties.find((p) => p.type === 'Property' &&
|
|
11
|
-
'name' in p.key &&
|
|
12
|
-
!p.computed &&
|
|
13
|
-
p.key.name === 'id');
|
|
14
|
-
if (!idProp)
|
|
15
|
-
return undefined;
|
|
16
|
-
const val = idProp.value;
|
|
5
|
+
const DOMAIN_ACTION_FILE_PATTERN = /[/\\]domains[/\\][^/\\]+[/\\]actions[/\\][^/\\]+\.ts$/;
|
|
6
|
+
function stringPropertyValue(prop) {
|
|
7
|
+
const val = prop.value;
|
|
17
8
|
if (val.type === 'Literal' && typeof val.value === 'string')
|
|
18
9
|
return val.value;
|
|
19
10
|
if (val.type === 'TemplateLiteral' && val.expressions.length === 0 && val.quasis.length === 1) {
|
|
@@ -21,29 +12,50 @@ function extractFixtureIdFromDefineApi(node) {
|
|
|
21
12
|
}
|
|
22
13
|
return undefined;
|
|
23
14
|
}
|
|
24
|
-
function
|
|
15
|
+
function findProperty(object, name) {
|
|
16
|
+
return object.properties.find((p) => p.type === 'Property' &&
|
|
17
|
+
'name' in p.key &&
|
|
18
|
+
!p.computed &&
|
|
19
|
+
p.key.name === name);
|
|
20
|
+
}
|
|
21
|
+
function extractFixtureIdFromApiAction(node) {
|
|
22
|
+
const arg = node.arguments[0];
|
|
23
|
+
if (!arg || arg.type !== 'ObjectExpression')
|
|
24
|
+
return undefined;
|
|
25
|
+
const exposureProp = findProperty(arg, 'exposure');
|
|
26
|
+
if (!exposureProp || exposureProp.value.type !== 'ObjectExpression')
|
|
27
|
+
return undefined;
|
|
28
|
+
const typeProp = findProperty(exposureProp.value, 'type');
|
|
29
|
+
if (!typeProp || stringPropertyValue(typeProp) !== 'api')
|
|
30
|
+
return undefined;
|
|
31
|
+
const idProp = findProperty(arg, 'id');
|
|
32
|
+
if (!idProp)
|
|
33
|
+
return undefined;
|
|
34
|
+
return stringPropertyValue(idProp);
|
|
35
|
+
}
|
|
36
|
+
function isDefineActionCall(node) {
|
|
25
37
|
if (!node)
|
|
26
38
|
return false;
|
|
27
|
-
return node.callee.type === 'Identifier' && node.callee.name === '
|
|
39
|
+
return node.callee.type === 'Identifier' && node.callee.name === 'defineAction';
|
|
28
40
|
}
|
|
29
41
|
export const apiNeedsFixture = createRule({
|
|
30
42
|
name: 'api-needs-fixture',
|
|
31
43
|
meta: {
|
|
32
44
|
type: 'problem',
|
|
33
45
|
docs: {
|
|
34
|
-
description: 'Every exported
|
|
46
|
+
description: 'Every exported API-exposed defineAction({...}) in domains/{x}/actions/*.ts must have a matching __tests__/{id}.fixture.json sibling file.',
|
|
35
47
|
},
|
|
36
48
|
messages: {
|
|
37
|
-
apiNeedsFixture: 'API
|
|
49
|
+
apiNeedsFixture: 'API-exposed action "{{name}}" is missing a __tests__/{{name}}.fixture.json sibling file.\n fixHint: Create `domains/<domain>/actions/__tests__/{{name}}.fixture.json` with realistic input/output examples. This file is consumed by `mc-domain-module test` to drive the handler in a local fixture run.\n kNodeRef: K:runbook:fixture-driven-tests',
|
|
38
50
|
},
|
|
39
51
|
schema: [],
|
|
40
52
|
},
|
|
41
53
|
defaultOptions: [],
|
|
42
54
|
create(context) {
|
|
43
55
|
const filename = context.filename ?? context.getFilename?.() ?? '';
|
|
44
|
-
if (!filename || !
|
|
56
|
+
if (!filename || !DOMAIN_ACTION_FILE_PATTERN.test(filename))
|
|
45
57
|
return {};
|
|
46
|
-
const
|
|
58
|
+
const actionDir = dirname(filename);
|
|
47
59
|
return {
|
|
48
60
|
ExportNamedDeclaration(node) {
|
|
49
61
|
const decl = node.declaration;
|
|
@@ -52,7 +64,7 @@ export const apiNeedsFixture = createRule({
|
|
|
52
64
|
let callNode;
|
|
53
65
|
if (decl.type === 'VariableDeclaration') {
|
|
54
66
|
for (const d of decl.declarations) {
|
|
55
|
-
if (d.init && d.init.type === 'CallExpression' &&
|
|
67
|
+
if (d.init && d.init.type === 'CallExpression' && isDefineActionCall(d.init)) {
|
|
56
68
|
callNode = d.init;
|
|
57
69
|
break;
|
|
58
70
|
}
|
|
@@ -63,16 +75,16 @@ export const apiNeedsFixture = createRule({
|
|
|
63
75
|
// with a double-cast through unknown to avoid TS's
|
|
64
76
|
// "may be a mistake" error on discriminated unions.
|
|
65
77
|
const d = decl;
|
|
66
|
-
if (d.type === 'CallExpression' &&
|
|
78
|
+
if (d.type === 'CallExpression' && isDefineActionCall(decl)) {
|
|
67
79
|
callNode = decl;
|
|
68
80
|
}
|
|
69
81
|
}
|
|
70
82
|
if (!callNode)
|
|
71
83
|
return;
|
|
72
|
-
const id =
|
|
84
|
+
const id = extractFixtureIdFromApiAction(callNode);
|
|
73
85
|
if (!id)
|
|
74
86
|
return;
|
|
75
|
-
const fixturePath = join(
|
|
87
|
+
const fixturePath = join(actionDir, '__tests__', `${id}.fixture.json`);
|
|
76
88
|
if (!existsSync(fixturePath)) {
|
|
77
89
|
context.report({ node, messageId: 'apiNeedsFixture', data: { name: id } });
|
|
78
90
|
}
|
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
import { ESLintUtils } from '@typescript-eslint/utils';
|
|
2
2
|
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/LawrenceGeeHouse/Mettlecast/blob/develop/packages/eslint-plugin-domain-module/docs/${name}.md`);
|
|
3
|
+
const SDK_TARGETS = [
|
|
4
|
+
{
|
|
5
|
+
match: 'client-lambda',
|
|
6
|
+
service: 'AWS Lambda',
|
|
7
|
+
ctxMethod: 'ctx.actions.call(\'<domain>.<action>\', input)',
|
|
8
|
+
reason: 'calling Lambda directly bypasses the action envelope, so the runtime cannot inject the tenant-scoped IAM principal, the auth tenant header, or the tracing/audit context that every other action call receives.',
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
match: 'client-dynamodb',
|
|
12
|
+
service: 'DynamoDB',
|
|
13
|
+
ctxMethod: 'ctx.store.{get,put,query,update,delete}',
|
|
14
|
+
reason: 'creating a DynamoDB client from a domain handler bypasses ctx.store, which automatically prefixes the partition key with the current tenantId — a raw call cannot enforce tenant isolation.',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
match: 'lib-dynamodb',
|
|
18
|
+
service: 'DynamoDB DocumentClient',
|
|
19
|
+
ctxMethod: 'ctx.store.{get,put,query,update,delete}',
|
|
20
|
+
reason: 'the document-client wrapper still requires tenant-scoped partition keys; ctx.store applies them for every call so domain code never sees the raw PK.',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
match: 'util-dynamodb',
|
|
24
|
+
service: 'DynamoDB marshalling',
|
|
25
|
+
ctxMethod: 'ctx.store.{get,put,query,update,delete}',
|
|
26
|
+
reason: 'marshalling helpers belong in the store adapter; if domain code is unmarshalling manually it is almost certainly calling DynamoDB through a side channel.',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
match: 'client-s3',
|
|
30
|
+
service: 'S3',
|
|
31
|
+
ctxMethod: 'ctx.files.{get,put,delete,list,getSignedUrl}',
|
|
32
|
+
reason: 'ctx.files prefixes every object key with `{tenantId}/`; a raw S3 client would need to re-implement that prefix and is the most common way to leak objects across tenants.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
match: 'client-secrets-manager',
|
|
36
|
+
service: 'Secrets Manager',
|
|
37
|
+
ctxMethod: 'ctx.secrets.<name>',
|
|
38
|
+
reason: 'ctx.secrets is the only path that runs with the tenant-scoped execution role and is auditable; reading raw secrets from a handler bypasses that.',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
match: 'client-eventbridge',
|
|
42
|
+
service: 'EventBridge',
|
|
43
|
+
ctxMethod: 'ctx.publish(\'<domain>.<event>\', data)',
|
|
44
|
+
reason: 'ctx.publish is the only event entrypoint that the schema registry tracks; raw PutEvents calls are invisible to the event catalog and to replay tooling.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
match: 'client-sns',
|
|
48
|
+
service: 'SNS',
|
|
49
|
+
ctxMethod: 'ctx.publish(\'<domain>.<event>\', data)',
|
|
50
|
+
reason: 'ctx.publish is the supported event entrypoint; raw SNS publishes cannot be replayed or schema-validated.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
match: 'client-sqs',
|
|
54
|
+
service: 'SQS',
|
|
55
|
+
ctxMethod: 'ctx.jobs.enqueue(\'<domain>.<job>\', input)',
|
|
56
|
+
reason: 'background work belongs on ctx.jobs so retries, visibility timeouts and DLQ wiring are managed by the runtime.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
match: 'client-sts',
|
|
60
|
+
service: 'STS',
|
|
61
|
+
ctxMethod: 'ctx (provided execution role)',
|
|
62
|
+
reason: 'assume-role from a domain handler is always wrong: the handler already runs with a tenant-scoped role and a cross-account assume would silently break tenant isolation.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
match: 'client-cognito-identity',
|
|
66
|
+
service: 'Cognito Identity',
|
|
67
|
+
ctxMethod: 'ctx (provided JWT authorizer)',
|
|
68
|
+
reason: 'domain handlers must rely on the JWT authorizer already wired by the runtime; using Cognito Identity directly bypasses the verified claim set.',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
match: 'client-cognito-identity-provider',
|
|
72
|
+
service: 'Cognito Identity Provider',
|
|
73
|
+
ctxMethod: 'ctx.integrations.cognito.* (or an internal admin action)',
|
|
74
|
+
reason: 'Cognito control-plane calls belong in a privileged integration or a system-tenancy admin action so the bootstrap secret and IAM scope are auditable.',
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
const FALLBACK_TARGET = {
|
|
78
|
+
match: '@aws-sdk/',
|
|
79
|
+
service: 'AWS',
|
|
80
|
+
ctxMethod: 'the corresponding ctx.* method',
|
|
81
|
+
reason: 'direct AWS SDK use in a domain handler bypasses the runtime wrappers that enforce tenancy, tracing, and the schema/registry contract.',
|
|
82
|
+
};
|
|
83
|
+
function classify(source) {
|
|
84
|
+
// Prefer the most-specific (longest) match so lib-dynamodb beats client-dynamodb.
|
|
85
|
+
const candidates = SDK_TARGETS
|
|
86
|
+
.filter((t) => source.includes(t.match))
|
|
87
|
+
.sort((a, b) => b.match.length - a.match.length);
|
|
88
|
+
return candidates[0] ?? FALLBACK_TARGET;
|
|
89
|
+
}
|
|
3
90
|
export const noRawAwsSdk = createRule({
|
|
4
91
|
name: 'no-raw-aws-sdk',
|
|
5
92
|
meta: {
|
|
@@ -8,7 +95,7 @@ export const noRawAwsSdk = createRule({
|
|
|
8
95
|
description: 'Disallow direct @aws-sdk/* imports in domain handler files. Use ctx.* instead.',
|
|
9
96
|
},
|
|
10
97
|
messages: {
|
|
11
|
-
noRawAwsSdk: 'Direct
|
|
98
|
+
noRawAwsSdk: 'Direct {{service}} SDK import "{{source}}" is not allowed in domain handlers — use {{ctxMethod}} instead.\n why: {{reason}}\n fixHint: Remove the `import ... from \'{{source}}\'` and route the call through the runtime context. If no ctx.* method exists for this AWS service, add one to `@mettlecast/domain-runtime` rather than reaching for the raw SDK.\n kNodeRef: K:convention:tier-1-foundations',
|
|
12
99
|
},
|
|
13
100
|
schema: [],
|
|
14
101
|
},
|
|
@@ -17,9 +104,19 @@ export const noRawAwsSdk = createRule({
|
|
|
17
104
|
return {
|
|
18
105
|
ImportDeclaration(node) {
|
|
19
106
|
const source = node.source.value;
|
|
20
|
-
if (typeof source
|
|
21
|
-
|
|
22
|
-
|
|
107
|
+
if (typeof source !== 'string' || !source.startsWith('@aws-sdk/'))
|
|
108
|
+
return;
|
|
109
|
+
const target = classify(source);
|
|
110
|
+
context.report({
|
|
111
|
+
node,
|
|
112
|
+
messageId: 'noRawAwsSdk',
|
|
113
|
+
data: {
|
|
114
|
+
source,
|
|
115
|
+
service: target.service,
|
|
116
|
+
ctxMethod: target.ctxMethod,
|
|
117
|
+
reason: target.reason,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
23
120
|
},
|
|
24
121
|
};
|
|
25
122
|
},
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { ESLintUtils } from '@typescript-eslint/utils';
|
|
2
|
+
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/LawrenceGeeHouse/Mettlecast/blob/develop/packages/eslint-plugin-domain-module/docs/${name}.md`);
|
|
3
|
+
/**
|
|
4
|
+
* Method names that indicate a low-level pg pool/client teardown.
|
|
5
|
+
*
|
|
6
|
+
* `release()` returns a checked-out `pg.PoolClient` to the pool. `end()`
|
|
7
|
+
* closes the entire pool. Both must never be called from domain code:
|
|
8
|
+
*
|
|
9
|
+
* - `client.release()` short-circuits the runtime's idempotent
|
|
10
|
+
* `ctx.db.release()` wrapper, so the next request that checks the
|
|
11
|
+
* client out inherits the previous request's `app.current_tenant`
|
|
12
|
+
* session variable — which means RLS is silently evaluated against the
|
|
13
|
+
* wrong tenant for one query.
|
|
14
|
+
*
|
|
15
|
+
* - `pool.end()` / `poolClient.end()` tears the pool down so subsequent
|
|
16
|
+
* requests race on a closed pool.
|
|
17
|
+
*
|
|
18
|
+
* Domain code should not call either. If a handler really needs to
|
|
19
|
+
* release early (e.g. for very long-running work), it should call
|
|
20
|
+
* `await ctx.db.release()` and let the runtime re-acquire the client —
|
|
21
|
+
* the runtime resets the session variables on every acquisition.
|
|
22
|
+
*/
|
|
23
|
+
const RELEASE_METHODS = new Set(['release', 'end']);
|
|
24
|
+
const PROPERTY_HINTS = ['client', 'pool', 'poolClient', 'pgClient'];
|
|
25
|
+
function chainText(member) {
|
|
26
|
+
// Best-effort text rendering for the diagnostic message. We don't try to
|
|
27
|
+
// resolve identifiers — we just walk the chain and concatenate names so
|
|
28
|
+
// the developer can recognise their pattern.
|
|
29
|
+
const parts = [];
|
|
30
|
+
let cur = member;
|
|
31
|
+
while (cur && cur.type === 'MemberExpression') {
|
|
32
|
+
if (cur.property.type === 'Identifier')
|
|
33
|
+
parts.unshift(cur.property.name);
|
|
34
|
+
cur = cur.object;
|
|
35
|
+
}
|
|
36
|
+
if (cur && cur.type === 'Identifier')
|
|
37
|
+
parts.unshift(cur.name);
|
|
38
|
+
return parts.join('.');
|
|
39
|
+
}
|
|
40
|
+
function matchesBypassPattern(member) {
|
|
41
|
+
// The pattern we care about is `<expr>.<intermediate>.release()` where
|
|
42
|
+
// `<intermediate>` is one of the pg-related hints, OR
|
|
43
|
+
// `<expr>.release()` where `<expr>` itself is one of the hints
|
|
44
|
+
// (covers `client.release()`, `pool.end()` etc.).
|
|
45
|
+
if (member.property.type !== 'Identifier')
|
|
46
|
+
return false;
|
|
47
|
+
if (!RELEASE_METHODS.has(member.property.name))
|
|
48
|
+
return false;
|
|
49
|
+
const inner = member.object;
|
|
50
|
+
if (inner.type === 'Identifier' && PROPERTY_HINTS.includes(inner.name))
|
|
51
|
+
return true;
|
|
52
|
+
if (inner.type === 'MemberExpression') {
|
|
53
|
+
if (inner.property.type !== 'Identifier')
|
|
54
|
+
return false;
|
|
55
|
+
return PROPERTY_HINTS.includes(inner.property.name);
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
export const noRawDbClientRelease = createRule({
|
|
60
|
+
name: 'no-raw-db-client-release',
|
|
61
|
+
meta: {
|
|
62
|
+
type: 'problem',
|
|
63
|
+
docs: {
|
|
64
|
+
description: 'Disallow low-level pg client.release()/pool.end() calls in domain handler files. Release is managed by the runtime so session variables stay scoped.',
|
|
65
|
+
},
|
|
66
|
+
messages: {
|
|
67
|
+
noRawDbClientRelease: 'Low-level Postgres teardown "{{call}}" is not allowed in domain handlers — the runtime owns client lifecycle.\n why: The runtime resets the Wave 5 session variables (`app.current_tenant`, `app.org_id`, `app.actor_id`) on every pool checkout. If domain code calls `client.release()` / `pool.end()` directly, the next handler that picks up the same client inherits the previous request\'s tenant binding and RLS evaluates against the wrong tenant for at least one query.\n fixHint: Remove the manual release/end call entirely. The runtime calls `await ctx.db.release()` after the handler returns — that path is idempotent and rebinds session variables on the next acquisition. If you need to release a long-running connection early, call `await ctx.db.release()` instead of touching the pg client directly. If this is a legitimate teardown in non-domain code, narrow the lint scope or add an `// eslint-disable-next-line` with a justifying comment.\n kNodeRef: K:convention:db-tenant-binding',
|
|
68
|
+
},
|
|
69
|
+
schema: [],
|
|
70
|
+
},
|
|
71
|
+
defaultOptions: [],
|
|
72
|
+
create(context) {
|
|
73
|
+
return {
|
|
74
|
+
CallExpression(node) {
|
|
75
|
+
const callee = node.callee;
|
|
76
|
+
if (callee.type !== 'MemberExpression')
|
|
77
|
+
return;
|
|
78
|
+
if (matchesBypassPattern(callee)) {
|
|
79
|
+
context.report({
|
|
80
|
+
node,
|
|
81
|
+
messageId: 'noRawDbClientRelease',
|
|
82
|
+
data: { call: chainText(callee) },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
});
|
|
@@ -9,7 +9,7 @@ export const noRawHttpServer = createRule({
|
|
|
9
9
|
description: 'Disallow HTTP server framework imports (express, koa, fastify, aws-lambda) in domain files.',
|
|
10
10
|
},
|
|
11
11
|
messages: {
|
|
12
|
-
noRawHttpServer: 'HTTP framework import "{{source}}" is not allowed in domain files. The Domain Module runtime handles transport binding.\n fixHint: Remove the `{{source}}` import. Domain primitives (
|
|
12
|
+
noRawHttpServer: 'HTTP framework import "{{source}}" is not allowed in domain files. The Domain Module runtime handles transport binding.\n fixHint: Remove the `{{source}}` import. Domain primitives (defineAction, defineWebhook) are transport-agnostic — the runtime handles HTTP binding.\n kNodeRef: K:convention:tier-1-foundations',
|
|
13
13
|
},
|
|
14
14
|
schema: [],
|
|
15
15
|
},
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ESLintUtils } from '@typescript-eslint/utils';
|
|
2
|
+
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/LawrenceGeeHouse/Mettlecast/blob/develop/packages/eslint-plugin-domain-module/docs/${name}.md`);
|
|
3
|
+
const PG_TARGETS = [
|
|
4
|
+
{ match: 'pg', label: 'node-postgres (pg)' },
|
|
5
|
+
{ match: 'pg-pool', label: 'pg.Pool' },
|
|
6
|
+
{ match: 'pg-cursor', label: 'pg-cursor' },
|
|
7
|
+
{ match: 'postgres', label: 'postgres (porsager/postgres)' },
|
|
8
|
+
{ match: 'pg-protocol', label: 'pg-protocol' },
|
|
9
|
+
{ match: 'pg-query-stream', label: 'pg-query-stream' },
|
|
10
|
+
];
|
|
11
|
+
const FALLBACK_LABEL = 'Postgres client';
|
|
12
|
+
function classify(source) {
|
|
13
|
+
const hit = PG_TARGETS.find((t) => source === t.match || source.startsWith(`${t.match}/`));
|
|
14
|
+
return hit?.label ?? FALLBACK_LABEL;
|
|
15
|
+
}
|
|
16
|
+
export const noRawPgClient = createRule({
|
|
17
|
+
name: 'no-raw-pg-client',
|
|
18
|
+
meta: {
|
|
19
|
+
type: 'problem',
|
|
20
|
+
docs: {
|
|
21
|
+
description: 'Disallow direct Postgres client imports in domain handler files. Use ctx.db from DomainContext.',
|
|
22
|
+
},
|
|
23
|
+
messages: {
|
|
24
|
+
noRawPgClient: 'Direct Postgres client import "{{source}}" is not allowed in domain handlers — {{label}} bypasses ctx.db.\n why: ctx.db is the only Postgres entrypoint that binds the Wave 5 session variables (`app.current_tenant`, `app.org_id`, `app.actor_id`) on the checked-out client. A raw pg client has no tenant bound, so RLS policies read stale tenant IDs from a previous request and leak rows across tenants.\n fixHint: Remove `import ... from \'{{source}}\'` and route the query through `await ctx.db.query(...)` or `await ctx.db.client.query(...)` — both of which already carry the tenant session variables. If a new pg helper is genuinely needed, add it to `@mettlecast/domain-runtime` rather than importing the raw client.\n kNodeRef: K:convention:db-tenant-binding',
|
|
25
|
+
},
|
|
26
|
+
schema: [],
|
|
27
|
+
},
|
|
28
|
+
defaultOptions: [],
|
|
29
|
+
create(context) {
|
|
30
|
+
return {
|
|
31
|
+
ImportDeclaration(node) {
|
|
32
|
+
const source = node.source.value;
|
|
33
|
+
if (typeof source !== 'string')
|
|
34
|
+
return;
|
|
35
|
+
// Allowlist: the runtime package itself imports `pg` to construct the
|
|
36
|
+
// tenant-bound pool, so we only flag imports when this rule runs on
|
|
37
|
+
// domain-source files (which it does via the recommended config's
|
|
38
|
+
// `files` filter in `infra/modules/eslint.config.js`).
|
|
39
|
+
if (!PG_TARGETS.some((t) => source === t.match || source.startsWith(`${t.match}/`)))
|
|
40
|
+
return;
|
|
41
|
+
context.report({
|
|
42
|
+
node,
|
|
43
|
+
messageId: 'noRawPgClient',
|
|
44
|
+
data: { source, label: classify(source) },
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ESLintUtils } from '@typescript-eslint/utils';
|
|
2
2
|
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/LawrenceGeeHouse/Mettlecast/blob/develop/packages/eslint-plugin-domain-module/docs/${name}.md`);
|
|
3
3
|
const DEFINE_CALLS = new Set([
|
|
4
|
-
'
|
|
4
|
+
'defineWebhook', 'defineSubscriber', 'defineSchedule',
|
|
5
5
|
'defineJob', 'defineAction', 'defineIntegration', 'defineEvent', 'defineDomain', 'defineFlow',
|
|
6
6
|
]);
|
|
7
7
|
export const requireDefinePrimitive = createRule({
|
|
@@ -12,7 +12,7 @@ export const requireDefinePrimitive = createRule({
|
|
|
12
12
|
description: 'Domain primitive files must export at least one define*() call result.',
|
|
13
13
|
},
|
|
14
14
|
messages: {
|
|
15
|
-
missingDefinePrimitive: 'No define*() call found in this domain primitive file. Export at least one
|
|
15
|
+
missingDefinePrimitive: 'No define*() call found in this domain primitive file. Export at least one defineAction(), defineWebhook(), defineSubscriber(), etc.\n fixHint: Wrap the file\'s export in a define*() call (e.g., `export const handler = defineAction({...})`). See domain.config.ts for the list of available primitives.\n kNodeRef: K:runbook:add-domain',
|
|
16
16
|
},
|
|
17
17
|
schema: [],
|
|
18
18
|
},
|