@mettlecast/eslint-plugin-domain-module 0.2.59 → 0.2.60

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 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 `defineApi` needs a sibling `__tests__/<id>.fixture.json` | error |
38
+ | `prefer-result-over-throw` | Domain API 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 four rules at their default severity levels:
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
- // ✓ Use DomainContext.db instead
68
- const { db } = ctx;
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
- // ✓ Use defineApi() to declare handlers
71
- export const chargeHandler = defineApi({
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
- // ✓ Only import exported types from other domains
77
- import type { PaymentEvent } from '@myorg/payments-contracts';
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
83
- - `@mettlecast/domain-cli` — validation and linting
116
+ - `@mettlecast/domain-runtime` — handler definitions and `DomainContext`
117
+ - `@mettlecast/domain-cli` — validation and linting
@@ -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
+ });
@@ -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 { defineApi } 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
+ });
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
  /**
@@ -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 AWS SDK import "{{source}}" is not allowed in domain handlers. Use ctx.* methods provided by DomainContext.\n fixHint: Replace `import ... from \'{{source}}\'` with the corresponding `ctx.*` method (e.g., ctx.db for DynamoDB, ctx.publish for EventBridge).\n kNodeRef: K:convention:tier-1-foundations',
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 === 'string' && /^@aws-sdk\//.test(source)) {
21
- context.report({ node, messageId: 'noRawAwsSdk', data: { source } });
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,4 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils';
2
+ export declare const noRawDbClientRelease: ESLintUtils.RuleModule<"noRawDbClientRelease", [], unknown, ESLintUtils.RuleListener> & {
3
+ name: string;
4
+ };
@@ -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
+ });
@@ -0,0 +1,4 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils';
2
+ export declare const noRawPgClient: ESLintUtils.RuleModule<"noRawPgClient", [], unknown, ESLintUtils.RuleListener> & {
3
+ name: string;
4
+ };
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/eslint-plugin-domain-module",
3
- "version": "0.2.59",
3
+ "version": "0.2.60",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"