@classytic/arc 2.4.1 → 2.4.2
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/index.mjs +1 -1
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/migrations/index.d.mts +113 -44
- package/dist/migrations/index.mjs +85 -99
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/{resourceToTools-B6ZN9Ing.mjs → resourceToTools-PMFE8HIv.mjs} +50 -6
- package/package.json +1 -1
- package/skills/arc/SKILL.md +14 -2
- package/skills/arc/references/mcp.md +47 -2
package/dist/index.mjs
CHANGED
|
@@ -126,6 +126,6 @@ function transform(name, handlerOrOptions) {
|
|
|
126
126
|
}
|
|
127
127
|
//#endregion
|
|
128
128
|
//#region src/index.ts
|
|
129
|
-
const version = "2.4.
|
|
129
|
+
const version = "2.4.2";
|
|
130
130
|
//#endregion
|
|
131
131
|
export { ArcError, BaseController, CRUD_OPERATIONS, DEFAULT_ID_FIELD, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_SORT, DEFAULT_TENANT_FIELD, DEFAULT_UPDATE_METHOD, ForbiddenError, HOOK_OPERATIONS, HOOK_PHASES, MAX_FILTER_DEPTH, MAX_REGEX_LENGTH, MAX_SEARCH_LENGTH, MUTATION_OPERATIONS, MongooseAdapter, NotFoundError, PrismaAdapter, RESERVED_QUERY_PARAMS, ResourceDefinition, SYSTEM_FIELDS, UnauthorizedError, ValidationError, adminOnly, allOf, allowPublic, anyOf, applyFieldReadPermissions, applyFieldWritePermissions, arcLog, assertValidConfig, authenticated, configureArcLogger, createDynamicPermissionMatrix, createMongooseAdapter, createOrgPermissions, createPrismaAdapter, defineResource, denyAll, fields, formatValidationErrors, fullPublic, getControllerScope, guard, intercept, middleware, ownerWithAdminBypass, presets_exports as permissions, pipe, publicRead, publicReadAdminWrite, readOnly, requestContext, requireAuth, requireOrgMembership, requireOrgRole, requireOwnership, requireRoles, requireTeamMembership, sortMiddlewares, transform, validateResourceConfig, version, when };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as fieldRulesToZod, r as createMcpServer, t as resourceToTools } from "../../resourceToTools-
|
|
1
|
+
import { n as fieldRulesToZod, r as createMcpServer, t as resourceToTools } from "../../resourceToTools-PMFE8HIv.mjs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import fp from "fastify-plugin";
|
|
4
4
|
//#region src/integrations/mcp/definePrompt.ts
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as createMcpServer, t as resourceToTools } from "../../resourceToTools-
|
|
1
|
+
import { r as createMcpServer, t as resourceToTools } from "../../resourceToTools-PMFE8HIv.mjs";
|
|
2
2
|
//#region src/integrations/mcp/testing.ts
|
|
3
3
|
/**
|
|
4
4
|
* @classytic/arc/mcp/testing — MCP Test Utilities
|
|
@@ -1,6 +1,41 @@
|
|
|
1
|
-
import mongoose from "mongoose";
|
|
2
|
-
|
|
3
1
|
//#region src/migrations/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Schema Versioning and Migrations System
|
|
4
|
+
*
|
|
5
|
+
* Manages database schema changes over time with version tracking.
|
|
6
|
+
* Supports forward migrations, rollbacks, and schema compatibility layers.
|
|
7
|
+
*
|
|
8
|
+
* DB-agnostic: the `db` parameter is typed as `unknown` — the user passes
|
|
9
|
+
* whatever connection object their adapter uses (Mongoose db, Prisma client,
|
|
10
|
+
* Knex instance, etc.) and their `up`/`down` functions cast it internally.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* import { defineMigration, MigrationRunner } from '@classytic/arc/migrations';
|
|
14
|
+
*
|
|
15
|
+
* const productV2 = defineMigration({
|
|
16
|
+
* version: 2,
|
|
17
|
+
* resource: 'product',
|
|
18
|
+
* up: async (db) => {
|
|
19
|
+
* const mongo = db as import('mongoose').mongo.Db;
|
|
20
|
+
* await mongo.collection('products').updateMany(
|
|
21
|
+
* {},
|
|
22
|
+
* { $rename: { 'oldField': 'newField' } }
|
|
23
|
+
* );
|
|
24
|
+
* },
|
|
25
|
+
* down: async (db) => {
|
|
26
|
+
* const mongo = db as import('mongoose').mongo.Db;
|
|
27
|
+
* await mongo.collection('products').updateMany(
|
|
28
|
+
* {},
|
|
29
|
+
* { $rename: { 'newField': 'oldField' } }
|
|
30
|
+
* );
|
|
31
|
+
* },
|
|
32
|
+
* });
|
|
33
|
+
*
|
|
34
|
+
* const runner = new MigrationRunner(mongoose.connection.db, {
|
|
35
|
+
* store: new MongoMigrationStore(mongoose.connection.db),
|
|
36
|
+
* });
|
|
37
|
+
* await runner.up(migrations);
|
|
38
|
+
*/
|
|
4
39
|
interface Migration {
|
|
5
40
|
/** Migration version (sequential number) */
|
|
6
41
|
version: number;
|
|
@@ -9,17 +44,18 @@ interface Migration {
|
|
|
9
44
|
/** Description of the migration */
|
|
10
45
|
description?: string;
|
|
11
46
|
/**
|
|
12
|
-
* Forward migration (apply schema change)
|
|
47
|
+
* Forward migration (apply schema change).
|
|
48
|
+
* The `db` parameter is whatever connection object you pass to the runner.
|
|
13
49
|
*/
|
|
14
|
-
up: (db:
|
|
50
|
+
up: (db: unknown) => Promise<void>;
|
|
15
51
|
/**
|
|
16
|
-
* Backward migration (revert schema change)
|
|
52
|
+
* Backward migration (revert schema change).
|
|
17
53
|
*/
|
|
18
|
-
down: (db:
|
|
54
|
+
down: (db: unknown) => Promise<void>;
|
|
19
55
|
/**
|
|
20
56
|
* Optional validation that data is compatible after migration
|
|
21
57
|
*/
|
|
22
|
-
validate?: (db:
|
|
58
|
+
validate?: (db: unknown) => Promise<boolean>;
|
|
23
59
|
}
|
|
24
60
|
interface MigrationRecord {
|
|
25
61
|
version: number;
|
|
@@ -28,6 +64,54 @@ interface MigrationRecord {
|
|
|
28
64
|
appliedAt: Date;
|
|
29
65
|
executionTime: number;
|
|
30
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* DB-agnostic migration store interface.
|
|
69
|
+
*
|
|
70
|
+
* Users implement this for their database:
|
|
71
|
+
* - MongoMigrationStore (uses a `_migrations` collection)
|
|
72
|
+
* - PrismaMigrationStore (uses a `_migrations` table)
|
|
73
|
+
* - or any custom store
|
|
74
|
+
*/
|
|
75
|
+
interface MigrationStore {
|
|
76
|
+
/** Get all applied migration records, sorted by appliedAt ascending */
|
|
77
|
+
getApplied(): Promise<MigrationRecord[]>;
|
|
78
|
+
/** Record a completed migration */
|
|
79
|
+
record(migration: Migration, executionTime: number): Promise<void>;
|
|
80
|
+
/** Remove a migration record (for rollback) */
|
|
81
|
+
remove(migration: Migration): Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Minimal logger interface — matches Fastify's logger, pino, console, etc.
|
|
85
|
+
*/
|
|
86
|
+
interface MigrationLogger {
|
|
87
|
+
info(msg: string): void;
|
|
88
|
+
error(msg: string): void;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* MongoDB-backed migration store.
|
|
92
|
+
*
|
|
93
|
+
* Uses a `_migrations` collection in the same database.
|
|
94
|
+
* The `db` parameter accepts any object with a `.collection()` method
|
|
95
|
+
* (Mongoose db, native MongoDB Db, etc.)
|
|
96
|
+
*/
|
|
97
|
+
declare class MongoMigrationStore implements MigrationStore {
|
|
98
|
+
private readonly collectionName;
|
|
99
|
+
private readonly db;
|
|
100
|
+
constructor(db: {
|
|
101
|
+
collection(name: string): any;
|
|
102
|
+
}, opts?: {
|
|
103
|
+
collectionName?: string;
|
|
104
|
+
});
|
|
105
|
+
getApplied(): Promise<MigrationRecord[]>;
|
|
106
|
+
record(migration: Migration, executionTime: number): Promise<void>;
|
|
107
|
+
remove(migration: Migration): Promise<void>;
|
|
108
|
+
}
|
|
109
|
+
interface MigrationRunnerOptions {
|
|
110
|
+
/** Migration store (required — use MongoMigrationStore or implement your own) */
|
|
111
|
+
store: MigrationStore;
|
|
112
|
+
/** Logger (defaults to process.stdout/stderr) */
|
|
113
|
+
logger?: MigrationLogger;
|
|
114
|
+
}
|
|
31
115
|
/**
|
|
32
116
|
* Define a migration
|
|
33
117
|
*/
|
|
@@ -35,12 +119,30 @@ declare function defineMigration(migration: Migration): Migration;
|
|
|
35
119
|
/**
|
|
36
120
|
* Migration Runner
|
|
37
121
|
*
|
|
38
|
-
* Manages execution of migrations with tracking and rollback
|
|
122
|
+
* DB-agnostic. Manages execution of migrations with tracking and rollback.
|
|
123
|
+
* The `db` parameter is passed through to migration `up`/`down` functions
|
|
124
|
+
* as-is — the runner never touches it directly.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```typescript
|
|
128
|
+
* // MongoDB
|
|
129
|
+
* const runner = new MigrationRunner(mongoose.connection.db, {
|
|
130
|
+
* store: new MongoMigrationStore(mongoose.connection.db),
|
|
131
|
+
* });
|
|
132
|
+
*
|
|
133
|
+
* // Prisma
|
|
134
|
+
* const runner = new MigrationRunner(prisma, {
|
|
135
|
+
* store: new PrismaMigrationStore(prisma), // user-implemented
|
|
136
|
+
* });
|
|
137
|
+
*
|
|
138
|
+
* await runner.up(migrations);
|
|
139
|
+
* ```
|
|
39
140
|
*/
|
|
40
141
|
declare class MigrationRunner {
|
|
41
|
-
private readonly collectionName;
|
|
42
142
|
private readonly db;
|
|
43
|
-
|
|
143
|
+
private readonly store;
|
|
144
|
+
private readonly log;
|
|
145
|
+
constructor(db: unknown, opts: MigrationRunnerOptions);
|
|
44
146
|
/**
|
|
45
147
|
* Run all pending migrations
|
|
46
148
|
*/
|
|
@@ -69,14 +171,6 @@ declare class MigrationRunner {
|
|
|
69
171
|
* Run a single migration
|
|
70
172
|
*/
|
|
71
173
|
private runMigration;
|
|
72
|
-
/**
|
|
73
|
-
* Record a completed migration
|
|
74
|
-
*/
|
|
75
|
-
private recordMigration;
|
|
76
|
-
/**
|
|
77
|
-
* Remove a migration record
|
|
78
|
-
*/
|
|
79
|
-
private removeMigration;
|
|
80
174
|
}
|
|
81
175
|
/**
|
|
82
176
|
* Schema version definition for resources
|
|
@@ -127,30 +221,5 @@ declare class MigrationRegistry {
|
|
|
127
221
|
*/
|
|
128
222
|
clear(): void;
|
|
129
223
|
}
|
|
130
|
-
/**
|
|
131
|
-
* Global migration registry instance
|
|
132
|
-
*/
|
|
133
|
-
declare const migrationRegistry: MigrationRegistry;
|
|
134
|
-
/**
|
|
135
|
-
* Common migration helpers
|
|
136
|
-
*/
|
|
137
|
-
declare const migrationHelpers: {
|
|
138
|
-
/**
|
|
139
|
-
* Rename a field across all documents
|
|
140
|
-
*/
|
|
141
|
-
renameField: (collection: string, oldName: string, newName: string) => Migration;
|
|
142
|
-
/**
|
|
143
|
-
* Add a new field with default value
|
|
144
|
-
*/
|
|
145
|
-
addField: (collection: string, fieldName: string, defaultValue: unknown) => Migration;
|
|
146
|
-
/**
|
|
147
|
-
* Remove a field
|
|
148
|
-
*/
|
|
149
|
-
removeField: (collection: string, fieldName: string) => Migration;
|
|
150
|
-
/**
|
|
151
|
-
* Create an index
|
|
152
|
-
*/
|
|
153
|
-
createIndex: (collection: string, fields: Record<string, 1 | -1>, options?: Record<string, unknown>) => Migration;
|
|
154
|
-
};
|
|
155
224
|
//#endregion
|
|
156
|
-
export { Migration, MigrationRecord, MigrationRegistry, MigrationRunner,
|
|
225
|
+
export { Migration, MigrationLogger, MigrationRecord, MigrationRegistry, MigrationRunner, MigrationRunnerOptions, MigrationStore, MongoMigrationStore, SchemaVersion, defineMigration, withSchemaVersion };
|
|
@@ -1,4 +1,42 @@
|
|
|
1
1
|
//#region src/migrations/index.ts
|
|
2
|
+
/** Default logger that writes to stdout/stderr */
|
|
3
|
+
const defaultLogger = {
|
|
4
|
+
info: (msg) => process.stdout.write(`${msg}\n`),
|
|
5
|
+
error: (msg) => process.stderr.write(`${msg}\n`)
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* MongoDB-backed migration store.
|
|
9
|
+
*
|
|
10
|
+
* Uses a `_migrations` collection in the same database.
|
|
11
|
+
* The `db` parameter accepts any object with a `.collection()` method
|
|
12
|
+
* (Mongoose db, native MongoDB Db, etc.)
|
|
13
|
+
*/
|
|
14
|
+
var MongoMigrationStore = class {
|
|
15
|
+
collectionName;
|
|
16
|
+
db;
|
|
17
|
+
constructor(db, opts) {
|
|
18
|
+
this.db = db;
|
|
19
|
+
this.collectionName = opts?.collectionName ?? "_migrations";
|
|
20
|
+
}
|
|
21
|
+
async getApplied() {
|
|
22
|
+
return await this.db.collection(this.collectionName).find({}).sort({ appliedAt: 1 }).toArray();
|
|
23
|
+
}
|
|
24
|
+
async record(migration, executionTime) {
|
|
25
|
+
await this.db.collection(this.collectionName).insertOne({
|
|
26
|
+
version: migration.version,
|
|
27
|
+
resource: migration.resource,
|
|
28
|
+
description: migration.description,
|
|
29
|
+
appliedAt: /* @__PURE__ */ new Date(),
|
|
30
|
+
executionTime
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async remove(migration) {
|
|
34
|
+
await this.db.collection(this.collectionName).deleteOne({
|
|
35
|
+
version: migration.version,
|
|
36
|
+
resource: migration.resource
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
};
|
|
2
40
|
/**
|
|
3
41
|
* Define a migration
|
|
4
42
|
*/
|
|
@@ -8,77 +46,97 @@ function defineMigration(migration) {
|
|
|
8
46
|
/**
|
|
9
47
|
* Migration Runner
|
|
10
48
|
*
|
|
11
|
-
* Manages execution of migrations with tracking and rollback
|
|
49
|
+
* DB-agnostic. Manages execution of migrations with tracking and rollback.
|
|
50
|
+
* The `db` parameter is passed through to migration `up`/`down` functions
|
|
51
|
+
* as-is — the runner never touches it directly.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* // MongoDB
|
|
56
|
+
* const runner = new MigrationRunner(mongoose.connection.db, {
|
|
57
|
+
* store: new MongoMigrationStore(mongoose.connection.db),
|
|
58
|
+
* });
|
|
59
|
+
*
|
|
60
|
+
* // Prisma
|
|
61
|
+
* const runner = new MigrationRunner(prisma, {
|
|
62
|
+
* store: new PrismaMigrationStore(prisma), // user-implemented
|
|
63
|
+
* });
|
|
64
|
+
*
|
|
65
|
+
* await runner.up(migrations);
|
|
66
|
+
* ```
|
|
12
67
|
*/
|
|
13
68
|
var MigrationRunner = class {
|
|
14
|
-
collectionName = "_migrations";
|
|
15
69
|
db;
|
|
16
|
-
|
|
70
|
+
store;
|
|
71
|
+
log;
|
|
72
|
+
constructor(db, opts) {
|
|
17
73
|
this.db = db;
|
|
74
|
+
this.store = opts.store;
|
|
75
|
+
this.log = opts.logger ?? defaultLogger;
|
|
18
76
|
}
|
|
19
77
|
/**
|
|
20
78
|
* Run all pending migrations
|
|
21
79
|
*/
|
|
22
80
|
async up(migrations) {
|
|
23
|
-
const applied = await this.
|
|
81
|
+
const applied = await this.store.getApplied();
|
|
24
82
|
const appliedVersions = new Set(applied.map((m) => `${m.resource}:${m.version}`));
|
|
25
83
|
const pending = migrations.filter((m) => !appliedVersions.has(`${m.resource}:${m.version}`)).sort((a, b) => a.version - b.version);
|
|
26
84
|
if (pending.length === 0) {
|
|
27
|
-
|
|
85
|
+
this.log.info("No pending migrations");
|
|
28
86
|
return;
|
|
29
87
|
}
|
|
30
|
-
|
|
88
|
+
this.log.info(`Running ${pending.length} migration(s)...`);
|
|
31
89
|
for (const migration of pending) await this.runMigration(migration, "up");
|
|
32
|
-
|
|
90
|
+
this.log.info("All migrations completed successfully");
|
|
33
91
|
}
|
|
34
92
|
/**
|
|
35
93
|
* Rollback last migration
|
|
36
94
|
*/
|
|
37
95
|
async down(migrations) {
|
|
38
|
-
const applied = await this.
|
|
96
|
+
const applied = await this.store.getApplied();
|
|
39
97
|
if (applied.length === 0) {
|
|
40
|
-
|
|
98
|
+
this.log.info("No migrations to rollback");
|
|
41
99
|
return;
|
|
42
100
|
}
|
|
43
101
|
const last = applied[applied.length - 1];
|
|
44
102
|
if (!last) {
|
|
45
|
-
|
|
103
|
+
this.log.info("No migrations to rollback");
|
|
46
104
|
return;
|
|
47
105
|
}
|
|
48
106
|
const migration = migrations.find((m) => m.resource === last.resource && m.version === last.version);
|
|
49
107
|
if (!migration) throw new Error(`Migration ${last.resource}:${last.version} not found in migration files`);
|
|
50
|
-
|
|
108
|
+
this.log.info(`Rolling back ${migration.resource} v${migration.version}...`);
|
|
51
109
|
await this.runMigration(migration, "down", true);
|
|
52
|
-
|
|
110
|
+
this.log.info("Rollback completed");
|
|
53
111
|
}
|
|
54
112
|
/**
|
|
55
113
|
* Rollback to specific version
|
|
56
114
|
*/
|
|
57
115
|
async downTo(migrations, targetVersion) {
|
|
58
|
-
const toRollback = (await this.
|
|
116
|
+
const toRollback = (await this.store.getApplied()).filter((m) => m.version > targetVersion).reverse();
|
|
59
117
|
if (toRollback.length === 0) {
|
|
60
|
-
|
|
118
|
+
this.log.info(`Already at or below version ${targetVersion}`);
|
|
61
119
|
return;
|
|
62
120
|
}
|
|
63
|
-
|
|
121
|
+
this.log.info(`Rolling back ${toRollback.length} migration(s)...`);
|
|
64
122
|
for (const record of toRollback) {
|
|
65
123
|
const migration = migrations.find((m) => m.resource === record.resource && m.version === record.version);
|
|
66
124
|
if (!migration) throw new Error(`Migration ${record.resource}:${record.version} not found`);
|
|
67
125
|
await this.runMigration(migration, "down", true);
|
|
68
126
|
}
|
|
69
|
-
|
|
127
|
+
this.log.info("Rollback completed");
|
|
70
128
|
}
|
|
71
129
|
/**
|
|
72
130
|
* Get all applied migrations
|
|
73
131
|
*/
|
|
74
132
|
async getAppliedMigrations() {
|
|
75
|
-
return
|
|
133
|
+
return this.store.getApplied();
|
|
76
134
|
}
|
|
77
135
|
/**
|
|
78
136
|
* Get pending migrations
|
|
79
137
|
*/
|
|
80
138
|
async getPendingMigrations(migrations) {
|
|
81
|
-
const applied = await this.
|
|
139
|
+
const applied = await this.store.getApplied();
|
|
82
140
|
const appliedVersions = new Set(applied.map((m) => `${m.resource}:${m.version}`));
|
|
83
141
|
return migrations.filter((m) => !appliedVersions.has(`${m.resource}:${m.version}`));
|
|
84
142
|
}
|
|
@@ -93,46 +151,28 @@ var MigrationRunner = class {
|
|
|
93
151
|
*/
|
|
94
152
|
async runMigration(migration, direction, isRollback = false) {
|
|
95
153
|
const start = Date.now();
|
|
96
|
-
|
|
154
|
+
const action = direction === "up" ? "Applying" : "Rolling back";
|
|
155
|
+
const label = `${migration.resource} v${migration.version}`;
|
|
156
|
+
const desc = migration.description ? `: ${migration.description}` : "";
|
|
157
|
+
this.log.info(`${action} ${label}${desc}...`);
|
|
97
158
|
try {
|
|
98
159
|
if (direction === "up") {
|
|
99
160
|
await migration.up(this.db);
|
|
100
161
|
if (migration.validate) {
|
|
101
162
|
if (!await migration.validate(this.db)) throw new Error("Migration validation failed");
|
|
102
163
|
}
|
|
103
|
-
await this.
|
|
164
|
+
await this.store.record(migration, Date.now() - start);
|
|
104
165
|
} else {
|
|
105
166
|
await migration.down(this.db);
|
|
106
|
-
if (isRollback) await this.
|
|
167
|
+
if (isRollback) await this.store.remove(migration);
|
|
107
168
|
}
|
|
108
169
|
const duration = Date.now() - start;
|
|
109
|
-
|
|
170
|
+
this.log.info(`${label} completed (${duration}ms)`);
|
|
110
171
|
} catch (error) {
|
|
111
|
-
|
|
172
|
+
this.log.error(`${label} failed: ${error.message}`);
|
|
112
173
|
throw error;
|
|
113
174
|
}
|
|
114
175
|
}
|
|
115
|
-
/**
|
|
116
|
-
* Record a completed migration
|
|
117
|
-
*/
|
|
118
|
-
async recordMigration(migration, executionTime) {
|
|
119
|
-
await this.db.collection(this.collectionName).insertOne({
|
|
120
|
-
version: migration.version,
|
|
121
|
-
resource: migration.resource,
|
|
122
|
-
description: migration.description,
|
|
123
|
-
appliedAt: /* @__PURE__ */ new Date(),
|
|
124
|
-
executionTime
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* Remove a migration record
|
|
129
|
-
*/
|
|
130
|
-
async removeMigration(migration) {
|
|
131
|
-
await this.db.collection(this.collectionName).deleteOne({
|
|
132
|
-
version: migration.version,
|
|
133
|
-
resource: migration.resource
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
176
|
};
|
|
137
177
|
/**
|
|
138
178
|
* Add versioning to resource definition
|
|
@@ -198,59 +238,5 @@ var MigrationRegistry = class {
|
|
|
198
238
|
this.migrations.clear();
|
|
199
239
|
}
|
|
200
240
|
};
|
|
201
|
-
/**
|
|
202
|
-
* Global migration registry instance
|
|
203
|
-
*/
|
|
204
|
-
const migrationRegistry = new MigrationRegistry();
|
|
205
|
-
/**
|
|
206
|
-
* Common migration helpers
|
|
207
|
-
*/
|
|
208
|
-
const migrationHelpers = {
|
|
209
|
-
renameField: (collection, oldName, newName) => defineMigration({
|
|
210
|
-
version: 0,
|
|
211
|
-
resource: collection,
|
|
212
|
-
description: `Rename ${oldName} to ${newName}`,
|
|
213
|
-
up: async (db) => {
|
|
214
|
-
await db.collection(collection).updateMany({}, { $rename: { [oldName]: newName } });
|
|
215
|
-
},
|
|
216
|
-
down: async (db) => {
|
|
217
|
-
await db.collection(collection).updateMany({}, { $rename: { [newName]: oldName } });
|
|
218
|
-
}
|
|
219
|
-
}),
|
|
220
|
-
addField: (collection, fieldName, defaultValue) => defineMigration({
|
|
221
|
-
version: 0,
|
|
222
|
-
resource: collection,
|
|
223
|
-
description: `Add ${fieldName} field`,
|
|
224
|
-
up: async (db) => {
|
|
225
|
-
await db.collection(collection).updateMany({ [fieldName]: { $exists: false } }, { $set: { [fieldName]: defaultValue } });
|
|
226
|
-
},
|
|
227
|
-
down: async (db) => {
|
|
228
|
-
await db.collection(collection).updateMany({}, { $unset: { [fieldName]: "" } });
|
|
229
|
-
}
|
|
230
|
-
}),
|
|
231
|
-
removeField: (collection, fieldName) => defineMigration({
|
|
232
|
-
version: 0,
|
|
233
|
-
resource: collection,
|
|
234
|
-
description: `Remove ${fieldName} field`,
|
|
235
|
-
up: async (db) => {
|
|
236
|
-
await db.collection(collection).updateMany({}, { $unset: { [fieldName]: "" } });
|
|
237
|
-
},
|
|
238
|
-
down: async (_db) => {
|
|
239
|
-
console.warn(`Cannot restore ${fieldName} field - data was deleted`);
|
|
240
|
-
}
|
|
241
|
-
}),
|
|
242
|
-
createIndex: (collection, fields, options) => defineMigration({
|
|
243
|
-
version: 0,
|
|
244
|
-
resource: collection,
|
|
245
|
-
description: `Create index on ${Object.keys(fields).join(", ")}`,
|
|
246
|
-
up: async (db) => {
|
|
247
|
-
await db.collection(collection).createIndex(fields, options);
|
|
248
|
-
},
|
|
249
|
-
down: async (db) => {
|
|
250
|
-
const indexName = typeof options?.name === "string" ? options.name : Object.keys(fields).join("_");
|
|
251
|
-
await db.collection(collection).dropIndex(indexName);
|
|
252
|
-
}
|
|
253
|
-
})
|
|
254
|
-
};
|
|
255
241
|
//#endregion
|
|
256
|
-
export { MigrationRegistry, MigrationRunner,
|
|
242
|
+
export { MigrationRegistry, MigrationRunner, MongoMigrationStore, defineMigration, withSchemaVersion };
|
|
@@ -44,7 +44,7 @@ try {
|
|
|
44
44
|
function createTracerProvider(options) {
|
|
45
45
|
if (!isAvailable) return null;
|
|
46
46
|
const { serviceName = "@classytic/arc", serviceVersion, exporterUrl = "http://localhost:4318/v1/traces" } = options;
|
|
47
|
-
const resolvedVersion = serviceVersion ?? "2.4.
|
|
47
|
+
const resolvedVersion = serviceVersion ?? "2.4.2";
|
|
48
48
|
const exporter = new OTLPTraceExporter({ url: exporterUrl });
|
|
49
49
|
const provider = new NodeTracerProvider({ resource: { attributes: {
|
|
50
50
|
"service.name": serviceName,
|
|
@@ -174,18 +174,19 @@ function buildListShape(fieldRules, options) {
|
|
|
174
174
|
* | update | { id } | {} | input minus id |
|
|
175
175
|
* | delete | { id } | {} | undefined |
|
|
176
176
|
*/
|
|
177
|
-
function buildRequestContext(input, auth, operation) {
|
|
177
|
+
function buildRequestContext(input, auth, operation, policyFilters) {
|
|
178
178
|
const scope = buildScope(auth);
|
|
179
179
|
const base = {
|
|
180
180
|
user: auth ? {
|
|
181
181
|
id: auth.userId,
|
|
182
|
-
_id: auth.userId
|
|
182
|
+
_id: auth.userId,
|
|
183
|
+
...auth
|
|
183
184
|
} : null,
|
|
184
185
|
headers: {},
|
|
185
186
|
context: {},
|
|
186
187
|
metadata: {
|
|
187
188
|
_scope: scope,
|
|
188
|
-
_policyFilters: {}
|
|
189
|
+
_policyFilters: policyFilters ?? {}
|
|
189
190
|
}
|
|
190
191
|
};
|
|
191
192
|
switch (operation) {
|
|
@@ -315,7 +316,7 @@ function resourceToTools(resource, config = {}) {
|
|
|
315
316
|
extraHideFields: config.hideFields,
|
|
316
317
|
filterableFields
|
|
317
318
|
}),
|
|
318
|
-
handler: createHandler(op, controller, resource.name)
|
|
319
|
+
handler: createHandler(op, controller, resource.name, resource.permissions)
|
|
319
320
|
});
|
|
320
321
|
}
|
|
321
322
|
for (const route of resource.additionalRoutes ?? []) {
|
|
@@ -381,7 +382,7 @@ function buildInputSchema(op, fieldRules, opts) {
|
|
|
381
382
|
case "delete": return { id: z.string().describe("Resource ID") };
|
|
382
383
|
}
|
|
383
384
|
}
|
|
384
|
-
function createHandler(op, controller, resourceName) {
|
|
385
|
+
function createHandler(op, controller, resourceName, permissions) {
|
|
385
386
|
const ctrl = controller;
|
|
386
387
|
return async (input, ctx) => {
|
|
387
388
|
try {
|
|
@@ -393,7 +394,15 @@ function createHandler(op, controller, resourceName) {
|
|
|
393
394
|
}],
|
|
394
395
|
isError: true
|
|
395
396
|
};
|
|
396
|
-
|
|
397
|
+
const policyFilters = await evaluatePermission(permissions?.[op], ctx.session, resourceName, op, input);
|
|
398
|
+
if (policyFilters === false) return {
|
|
399
|
+
content: [{
|
|
400
|
+
type: "text",
|
|
401
|
+
text: `Permission denied: ${op} on ${resourceName}`
|
|
402
|
+
}],
|
|
403
|
+
isError: true
|
|
404
|
+
};
|
|
405
|
+
return toCallToolResult(await method(buildRequestContext(input, ctx.session, op, policyFilters || void 0)));
|
|
397
406
|
} catch (err) {
|
|
398
407
|
const msg = err instanceof Error ? err.message : String(err);
|
|
399
408
|
ctx.log("error", `${resourceName}.${op}: ${msg}`).catch(() => {});
|
|
@@ -432,6 +441,41 @@ function createAdditionalRouteHandler(route, controller, hasId) {
|
|
|
432
441
|
}
|
|
433
442
|
};
|
|
434
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* Evaluate a resource's permission check in MCP context.
|
|
446
|
+
*
|
|
447
|
+
* Returns:
|
|
448
|
+
* - `false` if permission denied
|
|
449
|
+
* - `Record<string, unknown>` if granted with filters (ownership patterns)
|
|
450
|
+
* - `null` if granted without filters (or no permission check defined)
|
|
451
|
+
*/
|
|
452
|
+
async function evaluatePermission(check, session, resource, action, input) {
|
|
453
|
+
if (!check) return null;
|
|
454
|
+
const user = session ? {
|
|
455
|
+
id: session.userId,
|
|
456
|
+
_id: session.userId,
|
|
457
|
+
...session
|
|
458
|
+
} : null;
|
|
459
|
+
const result = await check({
|
|
460
|
+
user,
|
|
461
|
+
request: {
|
|
462
|
+
user,
|
|
463
|
+
headers: {},
|
|
464
|
+
params: {},
|
|
465
|
+
query: {},
|
|
466
|
+
body: input
|
|
467
|
+
},
|
|
468
|
+
resource,
|
|
469
|
+
action,
|
|
470
|
+
resourceId: typeof input.id === "string" ? input.id : void 0,
|
|
471
|
+
params: {},
|
|
472
|
+
data: input
|
|
473
|
+
});
|
|
474
|
+
if (typeof result === "boolean") return result ? null : false;
|
|
475
|
+
const permResult = result;
|
|
476
|
+
if (!permResult.granted) return false;
|
|
477
|
+
return permResult.filters ?? null;
|
|
478
|
+
}
|
|
435
479
|
function toCallToolResult(result) {
|
|
436
480
|
if (!result.success) return {
|
|
437
481
|
content: [{
|
package/package.json
CHANGED
package/skills/arc/SKILL.md
CHANGED
|
@@ -8,11 +8,11 @@ description: |
|
|
|
8
8
|
Triggers: arc, fastify resource, defineResource, createApp, BaseController, arc preset,
|
|
9
9
|
arc auth, arc events, arc jobs, arc websocket, arc mcp, arc plugin, arc testing, arc cli,
|
|
10
10
|
arc permissions, arc hooks, arc pipeline, arc factory, arc cache, arc QueryCache.
|
|
11
|
-
version: 2.4.
|
|
11
|
+
version: 2.4.2
|
|
12
12
|
license: MIT
|
|
13
13
|
metadata:
|
|
14
14
|
author: Classytic
|
|
15
|
-
version: "2.4.
|
|
15
|
+
version: "2.4.1"
|
|
16
16
|
tags:
|
|
17
17
|
- fastify
|
|
18
18
|
- rest-api
|
|
@@ -452,6 +452,18 @@ auth: async (headers) => {
|
|
|
452
452
|
|
|
453
453
|
**Multi-tenancy**: `organizationId` from auth flows into BaseController org-scoping automatically.
|
|
454
454
|
|
|
455
|
+
**Permission filters**: `PermissionResult.filters` from resource permissions flow into MCP tools — same as REST. Define once, works everywhere:
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
permissions: {
|
|
459
|
+
list: (ctx) => ({
|
|
460
|
+
granted: !!ctx.user,
|
|
461
|
+
filters: { orgId: ctx.user?.orgId, branchId: ctx.user?.branchId },
|
|
462
|
+
}),
|
|
463
|
+
}
|
|
464
|
+
// MCP tools automatically scope queries by orgId + branchId
|
|
465
|
+
```
|
|
466
|
+
|
|
455
467
|
**Project structure** — custom MCP tools co-located with resources:
|
|
456
468
|
|
|
457
469
|
```
|
|
@@ -35,9 +35,11 @@ Tool handlers call `BaseController` — same pipeline as REST (auth, org-scoping
|
|
|
35
35
|
| `serverName` | `string` | `'arc-mcp'` | Server identity |
|
|
36
36
|
| `serverVersion` | `string` | `'1.0.0'` | Server version |
|
|
37
37
|
| `instructions` | `string` | — | LLM guidance on tool usage |
|
|
38
|
+
| `include` | `string[]` | — | Only these resources get tools (overrides `exclude`) |
|
|
38
39
|
| `exclude` | `string[]` | — | Resource names to exclude |
|
|
39
|
-
| `toolNamePrefix` | `string` | — |
|
|
40
|
-
| `overrides` | `Record<string, McpResourceConfig>` | — | Per-resource
|
|
40
|
+
| `toolNamePrefix` | `string` | — | Global prefix: `'crm'` → `crm_list_products` |
|
|
41
|
+
| `overrides` | `Record<string, McpResourceConfig>` | — | Per-resource overrides (see below) |
|
|
42
|
+
| `authCacheTtlMs` | `number` | — | Cache auth results for N ms in stateless mode |
|
|
41
43
|
| `extraTools` | `ToolDefinition[]` | — | Hand-written tools alongside auto-generated |
|
|
42
44
|
| `extraPrompts` | `PromptDefinition[]` | — | Custom prompts |
|
|
43
45
|
| `stateful` | `boolean` | `false` | `false` = stateless (default, scalable). `true` = session-cached. |
|
|
@@ -52,6 +54,49 @@ Tool handlers call `BaseController` — same pipeline as REST (auth, org-scoping
|
|
|
52
54
|
| `create` | `destructiveHint: false` |
|
|
53
55
|
| `update`, `delete` | `destructiveHint: true, idempotentHint: true` |
|
|
54
56
|
|
|
57
|
+
### Per-Resource Overrides
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
await app.register(mcpPlugin, {
|
|
61
|
+
resources,
|
|
62
|
+
include: ['job', 'project'], // only expose these
|
|
63
|
+
overrides: {
|
|
64
|
+
job: {
|
|
65
|
+
operations: ['list', 'get'], // restrict ops
|
|
66
|
+
toolNamePrefix: 'db', // db_list_jobs, db_get_job
|
|
67
|
+
names: { get: 'get_job_by_id' }, // custom name for specific op
|
|
68
|
+
hideFields: ['internalScore'], // strip from schema
|
|
69
|
+
descriptions: { list: 'Browse jobs' }, // custom descriptions
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Permission Filters (v2.4.2)
|
|
76
|
+
|
|
77
|
+
Resource permissions with `filters` are automatically enforced in MCP tools — same as REST:
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
defineResource({
|
|
81
|
+
name: 'task',
|
|
82
|
+
permissions: {
|
|
83
|
+
list: (ctx) => ({
|
|
84
|
+
granted: !!ctx.user,
|
|
85
|
+
filters: { orgId: ctx.user?.orgId, branchId: ctx.user?.branchId },
|
|
86
|
+
}),
|
|
87
|
+
create: (ctx) => !!ctx.user, // boolean works too
|
|
88
|
+
delete: (ctx) => ({ granted: false, reason: 'Read-only' }), // deny
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// MCP tools automatically:
|
|
93
|
+
// - list_tasks scopes by orgId + branchId from permission filters
|
|
94
|
+
// - create_task allowed if user is authenticated
|
|
95
|
+
// - delete_task returns "Permission denied: delete on task"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
No extra config. `PermissionResult.filters` flow into `_policyFilters` → `BaseController.AccessControl`.
|
|
99
|
+
|
|
55
100
|
### Multiple MCP Endpoints
|
|
56
101
|
|
|
57
102
|
Mount separate servers scoped to different resource groups:
|