@everystack/mcp 0.4.5 → 0.4.7
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/dist/core.md +1 -1
- package/dist/handler-options.md +1 -0
- package/dist/index.cjs +34 -5
- package/dist/schema-patterns.md +28 -2
- package/package.json +4 -4
- package/src/gates/detectors/stage-name-as-trust-boundary.ts +70 -0
- package/src/gates/registry.ts +2 -0
- package/src/prompts/design-schema.ts +6 -3
- package/src/resources/core.md +1 -1
- package/src/resources/handler-options.md +1 -0
- package/src/resources/schema-patterns.md +28 -2
package/dist/core.md
CHANGED
|
@@ -104,7 +104,7 @@ const handler = createHandler(db, schema, {
|
|
|
104
104
|
protectedFields: { profiles: ['role'] }, // Strip from writes
|
|
105
105
|
rowOwnership: { posts: { column: 'authorId', userField: 'sub' } },
|
|
106
106
|
hooks: { posts: { beforeCreate: async (body, user) => ({ ...body, authorId: user?.sub }) } },
|
|
107
|
-
softDelete: { column: 'deletedAt', tables: ['posts'] },
|
|
107
|
+
softDelete: { column: 'deletedAt', tables: ['posts'] }, // Models: derived, don't hand-write
|
|
108
108
|
maxEmbedDepth: 3,
|
|
109
109
|
maxLimit: 1000,
|
|
110
110
|
});
|
package/dist/handler-options.md
CHANGED
|
@@ -100,6 +100,7 @@ maxLimit: 1000, // Max ?limit= value (default: 10000)
|
|
|
100
100
|
### Other
|
|
101
101
|
```typescript
|
|
102
102
|
softDelete: { column: 'deletedAt', tables: ['posts'] }, // DELETE -> UPDATE SET deletedAt
|
|
103
|
+
// ^ With Models this is DERIVED from each model's `softDelete: true` — do not hand-write it.
|
|
103
104
|
naming: 'snake_case', // Response key format ('camelCase' default)
|
|
104
105
|
```
|
|
105
106
|
|
package/dist/index.cjs
CHANGED
|
@@ -22411,9 +22411,12 @@ function registerDesignSchemaPrompt(server) {
|
|
|
22411
22411
|
"Declare each table with `defineModel`, following these conventions:",
|
|
22412
22412
|
"- UUID primary keys: `id: field.uuid().primaryKey().defaultRandom()`",
|
|
22413
22413
|
"- Timestamps: `createdAt: field.timestamptz().defaultNow().notNull()`",
|
|
22414
|
-
"- Soft delete: a `deletedAt: field.timestamptz()` field PLUS `softDelete: true` \u2014 the
|
|
22415
|
-
"
|
|
22416
|
-
"
|
|
22414
|
+
"- Soft delete: a `deletedAt: field.timestamptz()` field PLUS `softDelete: true` \u2014 the field alone",
|
|
22415
|
+
" does nothing. The flag decides DURABILITY (true makes DELETE mark deleted_at instead of",
|
|
22416
|
+
" destroying the row, and hides marked rows from the data API) and, where there is a public",
|
|
22417
|
+
" read, VISIBILITY (deleted_at IS NULL in the anon policy). defineModel REFUSES to guess on any",
|
|
22418
|
+
" model with deletedAt that has a public read or can be deleted from, so it throws at import if",
|
|
22419
|
+
" you omit it. Never hand-write the handler softDelete config \u2014 it is derived from this flag",
|
|
22417
22420
|
"- Foreign keys via relations: `field.uuid().references(() => Author)` / `belongsTo`/`hasMany`",
|
|
22418
22421
|
"- Sensitive columns: `.private()` (hidden from the API); write-guarded: `.readonly()`",
|
|
22419
22422
|
"- Named exports (PascalCase model var, e.g. `export const Post = defineModel('posts', \u2026)`)",
|
|
@@ -23521,11 +23524,37 @@ var secretInPublicEnv = {
|
|
|
23521
23524
|
}
|
|
23522
23525
|
};
|
|
23523
23526
|
|
|
23527
|
+
// src/gates/detectors/stage-name-as-trust-boundary.ts
|
|
23528
|
+
var STAGE_COMPARISON = /\b(?:ENVIRONMENT|environment|stage)\s*[=!]==\s*['"](?:dev|development|prod|production|staging|local)['"]/;
|
|
23529
|
+
var SECURITY_CONTEXT = /\b(?:details|error|stack|message|throw|drop|reset|seed|truncate|delete|secret|token|password|credential|auth|admin|bypass|skip|allow|disable|unsafe|insecure)/i;
|
|
23530
|
+
var stageNameAsTrustBoundary = {
|
|
23531
|
+
id: "stage-name-as-trust-boundary",
|
|
23532
|
+
tier: "framework",
|
|
23533
|
+
severity: "deny",
|
|
23534
|
+
guide: "A stage name is not a trust boundary \u2014 ENVIRONMENT comes from $app.stage, so this asks what someone named the stage, not whether the caller is trusted. Gate on an explicit option that defaults to the safe answer, and never return error text to a caller: return the requestId instead.",
|
|
23535
|
+
conform: "an explicit opt-in on the handler/plugin options (see config.allowDevelopment in @everystack/security apple-attest); for error responses, return { error, requestId } and debug with everystack logs:query --traceId <id>",
|
|
23536
|
+
verify: "pnpm --filter @everystack/server test \u2014 __tests__/error-response.test.ts fails on any stage-name gate",
|
|
23537
|
+
async detect(ctx) {
|
|
23538
|
+
if (ctx.tool !== "Write" && ctx.tool !== "Edit") return null;
|
|
23539
|
+
const text = ctx.content;
|
|
23540
|
+
if (!text || !/ENVIRONMENT|environment|stage/.test(text)) return null;
|
|
23541
|
+
const lines = text.split("\n");
|
|
23542
|
+
for (let i = 0; i < lines.length; i++) {
|
|
23543
|
+
if (!STAGE_COMPARISON.test(lines[i])) continue;
|
|
23544
|
+
const window = lines.slice(i, i + 3).join(" ");
|
|
23545
|
+
if (!SECURITY_CONTEXT.test(window)) continue;
|
|
23546
|
+
return `line ${i + 1} gates behaviour on the stage name (${lines[i].trim().slice(0, 80)}) \u2014 ENVIRONMENT comes from $app.stage, so this is a deployment label, not a trust decision`;
|
|
23547
|
+
}
|
|
23548
|
+
return null;
|
|
23549
|
+
}
|
|
23550
|
+
};
|
|
23551
|
+
|
|
23524
23552
|
// src/gates/registry.ts
|
|
23525
23553
|
var FRAMEWORK_GATES = [
|
|
23526
23554
|
handWrittenMigration,
|
|
23527
23555
|
embeddedDataBundle,
|
|
23528
|
-
secretInPublicEnv
|
|
23556
|
+
secretInPublicEnv,
|
|
23557
|
+
stageNameAsTrustBoundary
|
|
23529
23558
|
];
|
|
23530
23559
|
function gatesFor(_cwd) {
|
|
23531
23560
|
return FRAMEWORK_GATES;
|
|
@@ -23735,7 +23764,7 @@ async function runGovernanceCli(argv) {
|
|
|
23735
23764
|
}
|
|
23736
23765
|
|
|
23737
23766
|
// src/index.ts
|
|
23738
|
-
var version2 = (true ? "0.4.
|
|
23767
|
+
var version2 = (true ? "0.4.7" : null) ?? "0.3.0-dev";
|
|
23739
23768
|
var INSTRUCTIONS = [
|
|
23740
23769
|
"You govern how any agent builds everystack \u2014 a self-hosted application stack for Expo apps on AWS.",
|
|
23741
23770
|
"Your job is not only to advise but to keep the build on-script: the architecture the maintainer",
|
package/dist/schema-patterns.md
CHANGED
|
@@ -34,6 +34,9 @@ export const Post = defineModel('posts', {
|
|
|
34
34
|
updatedAt: field.timestamptz().notNull().defaultNow(),
|
|
35
35
|
deletedAt: field.timestamptz(),
|
|
36
36
|
},
|
|
37
|
+
// Required here, not optional: this model has a `deletedAt` field AND grants DELETE, so
|
|
38
|
+
// `defineModel` refuses to guess. See Soft Delete below for what each value means.
|
|
39
|
+
softDelete: true,
|
|
37
40
|
abilities: [can('read'), can('create'), can('update'), can('delete')],
|
|
38
41
|
});
|
|
39
42
|
```
|
|
@@ -59,11 +62,34 @@ updatedAt: field.timestamptz().notNull().defaultNow(),
|
|
|
59
62
|
```
|
|
60
63
|
|
|
61
64
|
### Soft Delete
|
|
62
|
-
|
|
65
|
+
The field alone does nothing. Declare BOTH — the column and what it means:
|
|
63
66
|
```typescript
|
|
64
67
|
deletedAt: field.timestamptz(),
|
|
68
|
+
softDelete: true, // on the model, beside `fields` — NOT in the handler
|
|
65
69
|
```
|
|
66
|
-
|
|
70
|
+
|
|
71
|
+
**`defineModel` REFUSES to guess** when a model has `deletedAt` and either a public read or a
|
|
72
|
+
way to DELETE (`can('delete')`, `can('manage')` — which is "all actions" — or a `privileges`
|
|
73
|
+
DELETE grant). Omit the line on such a model and it throws at import.
|
|
74
|
+
|
|
75
|
+
It is declared because it changes two different things, and neither default is safe:
|
|
76
|
+
|
|
77
|
+
- **Durability, on every model** — `true` makes a `DELETE` mark `deleted_at` instead of
|
|
78
|
+
removing the row, and then hides marked rows from the data API's reads and updates. `false`
|
|
79
|
+
makes `DELETE` permanent.
|
|
80
|
+
- **Visibility, only where there is a public read** — `true` AND-s `deleted_at IS NULL` into
|
|
81
|
+
the anon policy. On an owner-scoped or admin-only model the compiled SQL is identical either
|
|
82
|
+
way, so the durability half is the one that matters there.
|
|
83
|
+
|
|
84
|
+
Pick `true` if the row is meant to survive its own deletion — anything with a cleanup or purge
|
|
85
|
+
job, an audit trail, or rows other tables reference. Pick `false` if `deleted_at` here is
|
|
86
|
+
audit-only and a `DELETE` really should remove the row. Getting this wrong is not cosmetic: a
|
|
87
|
+
table whose S3 cleanup job finds work by `deleted_at IS NOT NULL` orphans every file it should
|
|
88
|
+
have purged if the row is hard-deleted instead.
|
|
89
|
+
|
|
90
|
+
**Do not hand-write the handler's `softDelete: { column, tables }`.** With Models, the handler
|
|
91
|
+
config is DERIVED from this flag (`deriveHandlerConfig`), so writing both is how the two drift
|
|
92
|
+
apart — and a hand-written list silently losing a table is exactly how this bug shipped.
|
|
67
93
|
|
|
68
94
|
### Foreign Keys
|
|
69
95
|
```typescript
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"description": "Governance layer that governs how any agent builds everystack — grounding, cheat gates, and Model-aware tooling over MCP",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"tsx": "4.21.0",
|
|
41
41
|
"typescript": "5.9.3",
|
|
42
42
|
"zod": "3.25.67",
|
|
43
|
-
"@everystack/cli": "0.4.
|
|
44
|
-
"@everystack/
|
|
45
|
-
"@everystack/
|
|
43
|
+
"@everystack/cli": "0.4.57",
|
|
44
|
+
"@everystack/model": "0.4.16",
|
|
45
|
+
"@everystack/server": "0.4.20"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
48
|
"test": "jest",
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheat: a stage name used as a trust boundary.
|
|
3
|
+
*
|
|
4
|
+
* `ENVIRONMENT` is set from `$app.stage` by the SST helper, so `ENVIRONMENT === 'dev'` does not
|
|
5
|
+
* ask "is this caller trusted" — it asks "what did someone call this stage". The two questions
|
|
6
|
+
* look identical in a diff and diverge the moment a stage named `dev` is publicly reachable.
|
|
7
|
+
*
|
|
8
|
+
* This shipped in the framework and reached two consumers, neither of whom chose anything but a
|
|
9
|
+
* stage name: a 500 returned raw PostgreSQL text — relation and column names — to unauthenticated
|
|
10
|
+
* callers. It survived its first fix because the condition had been COPIED into a second file and
|
|
11
|
+
* only one was found, which is why this is a gate and not a note in a review checklist.
|
|
12
|
+
*
|
|
13
|
+
* The correct shape is next door in `@everystack/security`: apple-attest reads the environment
|
|
14
|
+
* from the attestation CERTIFICATE (a fact about the client, not a deployment label) and still
|
|
15
|
+
* requires an explicit `config.allowDevelopment` to relax. Derive the fact from something
|
|
16
|
+
* trustworthy, then require an opt-in that fails closed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { CheatGate, ToolCallContext } from '../types.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A stage/environment label compared against a deployment name. Covers the spellings that have
|
|
23
|
+
* actually appeared: `process.env.ENVIRONMENT === 'dev'`, `ctx.environment !== 'dev'`, and the
|
|
24
|
+
* inverted `!== 'production'` form that reads as "anywhere but prod".
|
|
25
|
+
*/
|
|
26
|
+
const STAGE_COMPARISON =
|
|
27
|
+
/\b(?:ENVIRONMENT|environment|stage)\s*[=!]==\s*['"](?:dev|development|prod|production|staging|local)['"]/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Security-relevant company on the same line or nearby: what makes a stage comparison a GATE
|
|
31
|
+
* rather than a label choice. Picking which docs string to print by stage is fine; deciding what
|
|
32
|
+
* a caller may see, or whether a destructive verb runs, is not.
|
|
33
|
+
*/
|
|
34
|
+
// No trailing \b: these appear camelCased far more often than bare — `skipAuth`, `allowInsecure`,
|
|
35
|
+
// `disableRls`, `adminOnly`. Requiring a word boundary on the right made the detector miss
|
|
36
|
+
// `if (ENVIRONMENT !== 'production') { skipAuth = true }`, which is the exact shape it exists for.
|
|
37
|
+
const SECURITY_CONTEXT =
|
|
38
|
+
/\b(?:details|error|stack|message|throw|drop|reset|seed|truncate|delete|secret|token|password|credential|auth|admin|bypass|skip|allow|disable|unsafe|insecure)/i;
|
|
39
|
+
|
|
40
|
+
export const stageNameAsTrustBoundary: CheatGate = {
|
|
41
|
+
id: 'stage-name-as-trust-boundary',
|
|
42
|
+
tier: 'framework',
|
|
43
|
+
severity: 'deny',
|
|
44
|
+
guide:
|
|
45
|
+
'A stage name is not a trust boundary — ENVIRONMENT comes from $app.stage, so this asks what someone named the stage, not whether the caller is trusted. Gate on an explicit option that defaults to the safe answer, and never return error text to a caller: return the requestId instead.',
|
|
46
|
+
conform:
|
|
47
|
+
'an explicit opt-in on the handler/plugin options (see config.allowDevelopment in @everystack/security apple-attest); for error responses, return { error, requestId } and debug with everystack logs:query --traceId <id>',
|
|
48
|
+
verify:
|
|
49
|
+
'pnpm --filter @everystack/server test — __tests__/error-response.test.ts fails on any stage-name gate',
|
|
50
|
+
|
|
51
|
+
async detect(ctx: ToolCallContext): Promise<string | null> {
|
|
52
|
+
if (ctx.tool !== 'Write' && ctx.tool !== 'Edit') return null;
|
|
53
|
+
const text = ctx.content;
|
|
54
|
+
// Cheap pre-filter: only content naming a stage label pays for the line scan.
|
|
55
|
+
if (!text || !/ENVIRONMENT|environment|stage/.test(text)) return null;
|
|
56
|
+
|
|
57
|
+
const lines = text.split('\n');
|
|
58
|
+
for (let i = 0; i < lines.length; i++) {
|
|
59
|
+
if (!STAGE_COMPARISON.test(lines[i])) continue;
|
|
60
|
+
|
|
61
|
+
// The comparison alone is not the cheat — `channel = ENVIRONMENT` is legitimate. Look at the
|
|
62
|
+
// line and the two after it, which is where the guarded body sits.
|
|
63
|
+
const window = lines.slice(i, i + 3).join(' ');
|
|
64
|
+
if (!SECURITY_CONTEXT.test(window)) continue;
|
|
65
|
+
|
|
66
|
+
return `line ${i + 1} gates behaviour on the stage name (${lines[i].trim().slice(0, 80)}) — ENVIRONMENT comes from $app.stage, so this is a deployment label, not a trust decision`;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
},
|
|
70
|
+
};
|
package/src/gates/registry.ts
CHANGED
|
@@ -11,12 +11,14 @@ import type { CheatGate } from './types.js';
|
|
|
11
11
|
import { handWrittenMigration } from './detectors/hand-written-migration.js';
|
|
12
12
|
import { embeddedDataBundle } from './detectors/embedded-data-bundle.js';
|
|
13
13
|
import { secretInPublicEnv } from './detectors/secret-in-public-env.js';
|
|
14
|
+
import { stageNameAsTrustBoundary } from './detectors/stage-name-as-trust-boundary.js';
|
|
14
15
|
|
|
15
16
|
/** Framework-tier gates, on by default. */
|
|
16
17
|
export const FRAMEWORK_GATES: CheatGate[] = [
|
|
17
18
|
handWrittenMigration,
|
|
18
19
|
embeddedDataBundle,
|
|
19
20
|
secretInPublicEnv,
|
|
21
|
+
stageNameAsTrustBoundary,
|
|
20
22
|
];
|
|
21
23
|
|
|
22
24
|
/** The gates that apply to a working directory. Project-tier composition is added later. */
|
|
@@ -41,9 +41,12 @@ export function registerDesignSchemaPrompt(server: McpServer): void {
|
|
|
41
41
|
'Declare each table with `defineModel`, following these conventions:',
|
|
42
42
|
'- UUID primary keys: `id: field.uuid().primaryKey().defaultRandom()`',
|
|
43
43
|
'- Timestamps: `createdAt: field.timestamptz().defaultNow().notNull()`',
|
|
44
|
-
'- Soft delete: a `deletedAt: field.timestamptz()` field PLUS `softDelete: true` — the
|
|
45
|
-
'
|
|
46
|
-
'
|
|
44
|
+
'- Soft delete: a `deletedAt: field.timestamptz()` field PLUS `softDelete: true` — the field alone',
|
|
45
|
+
' does nothing. The flag decides DURABILITY (true makes DELETE mark deleted_at instead of',
|
|
46
|
+
' destroying the row, and hides marked rows from the data API) and, where there is a public',
|
|
47
|
+
' read, VISIBILITY (deleted_at IS NULL in the anon policy). defineModel REFUSES to guess on any',
|
|
48
|
+
' model with deletedAt that has a public read or can be deleted from, so it throws at import if',
|
|
49
|
+
' you omit it. Never hand-write the handler softDelete config — it is derived from this flag',
|
|
47
50
|
'- Foreign keys via relations: `field.uuid().references(() => Author)` / `belongsTo`/`hasMany`',
|
|
48
51
|
'- Sensitive columns: `.private()` (hidden from the API); write-guarded: `.readonly()`',
|
|
49
52
|
'- Named exports (PascalCase model var, e.g. `export const Post = defineModel(\'posts\', …)`)',
|
package/src/resources/core.md
CHANGED
|
@@ -104,7 +104,7 @@ const handler = createHandler(db, schema, {
|
|
|
104
104
|
protectedFields: { profiles: ['role'] }, // Strip from writes
|
|
105
105
|
rowOwnership: { posts: { column: 'authorId', userField: 'sub' } },
|
|
106
106
|
hooks: { posts: { beforeCreate: async (body, user) => ({ ...body, authorId: user?.sub }) } },
|
|
107
|
-
softDelete: { column: 'deletedAt', tables: ['posts'] },
|
|
107
|
+
softDelete: { column: 'deletedAt', tables: ['posts'] }, // Models: derived, don't hand-write
|
|
108
108
|
maxEmbedDepth: 3,
|
|
109
109
|
maxLimit: 1000,
|
|
110
110
|
});
|
|
@@ -100,6 +100,7 @@ maxLimit: 1000, // Max ?limit= value (default: 10000)
|
|
|
100
100
|
### Other
|
|
101
101
|
```typescript
|
|
102
102
|
softDelete: { column: 'deletedAt', tables: ['posts'] }, // DELETE -> UPDATE SET deletedAt
|
|
103
|
+
// ^ With Models this is DERIVED from each model's `softDelete: true` — do not hand-write it.
|
|
103
104
|
naming: 'snake_case', // Response key format ('camelCase' default)
|
|
104
105
|
```
|
|
105
106
|
|
|
@@ -34,6 +34,9 @@ export const Post = defineModel('posts', {
|
|
|
34
34
|
updatedAt: field.timestamptz().notNull().defaultNow(),
|
|
35
35
|
deletedAt: field.timestamptz(),
|
|
36
36
|
},
|
|
37
|
+
// Required here, not optional: this model has a `deletedAt` field AND grants DELETE, so
|
|
38
|
+
// `defineModel` refuses to guess. See Soft Delete below for what each value means.
|
|
39
|
+
softDelete: true,
|
|
37
40
|
abilities: [can('read'), can('create'), can('update'), can('delete')],
|
|
38
41
|
});
|
|
39
42
|
```
|
|
@@ -59,11 +62,34 @@ updatedAt: field.timestamptz().notNull().defaultNow(),
|
|
|
59
62
|
```
|
|
60
63
|
|
|
61
64
|
### Soft Delete
|
|
62
|
-
|
|
65
|
+
The field alone does nothing. Declare BOTH — the column and what it means:
|
|
63
66
|
```typescript
|
|
64
67
|
deletedAt: field.timestamptz(),
|
|
68
|
+
softDelete: true, // on the model, beside `fields` — NOT in the handler
|
|
65
69
|
```
|
|
66
|
-
|
|
70
|
+
|
|
71
|
+
**`defineModel` REFUSES to guess** when a model has `deletedAt` and either a public read or a
|
|
72
|
+
way to DELETE (`can('delete')`, `can('manage')` — which is "all actions" — or a `privileges`
|
|
73
|
+
DELETE grant). Omit the line on such a model and it throws at import.
|
|
74
|
+
|
|
75
|
+
It is declared because it changes two different things, and neither default is safe:
|
|
76
|
+
|
|
77
|
+
- **Durability, on every model** — `true` makes a `DELETE` mark `deleted_at` instead of
|
|
78
|
+
removing the row, and then hides marked rows from the data API's reads and updates. `false`
|
|
79
|
+
makes `DELETE` permanent.
|
|
80
|
+
- **Visibility, only where there is a public read** — `true` AND-s `deleted_at IS NULL` into
|
|
81
|
+
the anon policy. On an owner-scoped or admin-only model the compiled SQL is identical either
|
|
82
|
+
way, so the durability half is the one that matters there.
|
|
83
|
+
|
|
84
|
+
Pick `true` if the row is meant to survive its own deletion — anything with a cleanup or purge
|
|
85
|
+
job, an audit trail, or rows other tables reference. Pick `false` if `deleted_at` here is
|
|
86
|
+
audit-only and a `DELETE` really should remove the row. Getting this wrong is not cosmetic: a
|
|
87
|
+
table whose S3 cleanup job finds work by `deleted_at IS NOT NULL` orphans every file it should
|
|
88
|
+
have purged if the row is hard-deleted instead.
|
|
89
|
+
|
|
90
|
+
**Do not hand-write the handler's `softDelete: { column, tables }`.** With Models, the handler
|
|
91
|
+
config is DERIVED from this flag (`deriveHandlerConfig`), so writing both is how the two drift
|
|
92
|
+
apart — and a hand-written list silently losing a table is exactly how this bug shipped.
|
|
67
93
|
|
|
68
94
|
### Foreign Keys
|
|
69
95
|
```typescript
|