@classytic/arc 2.20.0 → 2.21.0

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.
Files changed (111) hide show
  1. package/dist/{BaseController-D-MHZ27_.mjs → BaseController-BnLYgE9A.mjs} +2 -2
  2. package/dist/{EventTransport-IEn7Tn-Q.d.mts → EventTransport-QmY7A258.d.mts} +0 -1
  3. package/dist/{QueryCache-TRfXau3W.d.mts → QueryCache-Z1B38koh.d.mts} +0 -1
  4. package/dist/{actionPermissions-C9RMiIpc.mjs → actionPermissions-ze70xXJi.mjs} +1 -1
  5. package/dist/audit/index.d.mts +9 -5
  6. package/dist/auth/audit.d.mts +7 -4
  7. package/dist/auth/audit.mjs +5 -0
  8. package/dist/auth/index.d.mts +7 -14
  9. package/dist/auth/index.mjs +1 -1
  10. package/dist/auth/redis-session.d.mts +1 -2
  11. package/dist/cache/index.d.mts +2 -3
  12. package/dist/cli/commands/describe.d.mts +1 -2
  13. package/dist/cli/commands/docs.mjs +1 -1
  14. package/dist/cli/commands/init.mjs +1 -1
  15. package/dist/cli/index.mjs +1 -1
  16. package/dist/context/index.d.mts +2 -2
  17. package/dist/core/index.d.mts +3 -3
  18. package/dist/core/index.mjs +4 -4
  19. package/dist/{core-CWhyGkiM.mjs → core-DZ6Xe1jR.mjs} +65 -10
  20. package/dist/{createActionRouter-HlQso0jO.mjs → createActionRouter-BWMDW-dL.mjs} +2 -2
  21. package/dist/{createAggregationRouter-5LzPzgw1.mjs → createAggregationRouter-l5mNdYrZ.mjs} +1 -1
  22. package/dist/{createApp-D6uwzCKu.mjs → createApp-Bi61XxqU.mjs} +149 -137
  23. package/dist/discovery/index.d.mts +0 -1
  24. package/dist/docs/index.d.mts +1 -2
  25. package/dist/docs/index.mjs +1 -1
  26. package/dist/{elevation-xGUBq6p_.d.mts → elevation-C9s5hFP_.d.mts} +0 -1
  27. package/dist/encryption/index.d.mts +2 -144
  28. package/dist/{errorHandler-BtUUDPEa.d.mts → errorHandler-B0e-bq-O.d.mts} +0 -1
  29. package/dist/{errors-DOHkjQlD.d.mts → errors-DHcHVAgk.d.mts} +0 -1
  30. package/dist/{eventPlugin-DbBFd3x_.d.mts → eventPlugin-A6EbthXR.d.mts} +10 -6
  31. package/dist/events/index.d.mts +9 -6
  32. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  33. package/dist/events/transports/redis.d.mts +1 -2
  34. package/dist/factory/index.d.mts +4 -4
  35. package/dist/factory/index.mjs +2 -2
  36. package/dist/{fastify-augmentation-CCQJEDgh.d.mts → fastify-augmentation-DafigGQp.d.mts} +2 -3
  37. package/dist/{fields-C7ahdGYf.d.mts → fields-BGqrAa_u.d.mts} +1 -2
  38. package/dist/hooks/index.d.mts +1 -1
  39. package/dist/idempotency/index.d.mts +4 -3
  40. package/dist/idempotency/redis.d.mts +1 -1
  41. package/dist/{index-DUa8WHz3.d.mts → index-B5N-NaHs.d.mts} +88 -7
  42. package/dist/{index-CdFkIMEd.d.mts → index-B5PoNbfu.d.mts} +36 -7
  43. package/dist/{index-_--GyzcO.d.mts → index-Dtrj6XZk.d.mts} +62 -28
  44. package/dist/index.d.mts +7 -7
  45. package/dist/index.mjs +6 -6
  46. package/dist/{init-YH_nslQR.mjs → init-0YhHkXGk.mjs} +154 -137
  47. package/dist/integrations/event-gateway.d.mts +2 -3
  48. package/dist/integrations/index.d.mts +2 -2
  49. package/dist/integrations/jobs.d.mts +0 -1
  50. package/dist/integrations/mcp/index.d.mts +6 -7
  51. package/dist/integrations/mcp/index.mjs +1 -1
  52. package/dist/integrations/mcp/testing.d.mts +1 -2
  53. package/dist/integrations/mcp/testing.mjs +1 -1
  54. package/dist/integrations/streamline.d.mts +2 -2
  55. package/dist/integrations/webhooks.d.mts +0 -1
  56. package/dist/integrations/websocket-pushref-redis.d.mts +1 -2
  57. package/dist/integrations/websocket-redis.d.mts +1 -2
  58. package/dist/integrations/websocket.d.mts +2 -2
  59. package/dist/{loadResources-B8UmpITt.mjs → loadResources-DpU8pfjV.mjs} +4 -3
  60. package/dist/{mcpPlugin-CuW6L_uD.mjs → mcpPlugin-B9XjjNVG.mjs} +3 -3
  61. package/dist/middleware/index.d.mts +1 -2
  62. package/dist/{module-JYH9V0I8.d.mts → module-WeuU5jfd.d.mts} +46 -1
  63. package/dist/{openapi-Dq6RbEGK.mjs → openapi-Kyod_di6.mjs} +2 -2
  64. package/dist/permissions/index.d.mts +3 -3
  65. package/dist/permissions/index.mjs +2 -2
  66. package/dist/{permissions-CgbOLJZG.mjs → permissions-BJMuKMl7.mjs} +33 -1
  67. package/dist/pipeline/index.d.mts +1 -2
  68. package/dist/plugins/index.d.mts +79 -7
  69. package/dist/plugins/index.mjs +88 -1
  70. package/dist/plugins/response-cache.d.mts +5 -3
  71. package/dist/plugins/tracing-entry.d.mts +1 -1
  72. package/dist/plugins/tracing-entry.mjs +1 -1
  73. package/dist/presets/filesUpload.d.mts +4 -5
  74. package/dist/presets/filesUpload.mjs +1 -1
  75. package/dist/presets/index.d.mts +1 -2
  76. package/dist/presets/index.mjs +1 -1
  77. package/dist/presets/multiTenant.d.mts +1 -2
  78. package/dist/presets/search.d.mts +2 -3
  79. package/dist/presets/search.mjs +1 -1
  80. package/dist/{presets-DWsYQdbV.mjs → presets-gw0ciIUD.mjs} +1 -1
  81. package/dist/{pushref-registry-DnCqL3aa.d.mts → pushref-registry-BbcnpNTm.d.mts} +4 -2
  82. package/dist/{queryCachePlugin-xzFyKAjT.d.mts → queryCachePlugin-CfYWq-Ak.d.mts} +1 -2
  83. package/dist/{redis-C3W0qbz8.d.mts → redis-UDtLaNj6.d.mts} +0 -1
  84. package/dist/{redis-stream-DsP1vqZB.d.mts → redis-stream-iVR2l7aS.d.mts} +1 -2
  85. package/dist/{registerRoute-DGSREwPw.mjs → registerRoute-oGAl0oA8.mjs} +1 -1
  86. package/dist/registry/index.d.mts +2 -2
  87. package/dist/schemas/index.d.mts +0 -1
  88. package/dist/scim/index.d.mts +0 -1
  89. package/dist/scope/index.d.mts +2 -3
  90. package/dist/{sessionManager-BXG-HGwG.d.mts → sessionManager-BHOM3N_0.d.mts} +10 -6
  91. package/dist/{storage-Co_PQpeQ.d.mts → storage-DLUZ04kK.d.mts} +8 -4
  92. package/dist/testing/index.d.mts +2 -3
  93. package/dist/testing/index.mjs +5 -2
  94. package/dist/testing/storageContract.d.mts +1 -2
  95. package/dist/{tracing-D8jlGXP7.d.mts → tracing-CsGKQJGg.d.mts} +0 -1
  96. package/dist/types/index.d.mts +4 -4
  97. package/dist/types/storage.d.mts +1 -1
  98. package/dist/types-Cc91nXuA.d.mts +132 -0
  99. package/dist/{types-LNebCoJx.d.mts → types-DH0GupI7.d.mts} +8 -4
  100. package/dist/{types-C9b2HR5W.d.mts → types-DRj-Ut4n.d.mts} +1 -2
  101. package/dist/{types-BHMKlMBU.d.mts → types-DpxoNX5v.d.mts} +61 -21
  102. package/dist/utils/index.d.mts +182 -128
  103. package/dist/utils/index.mjs +2 -2
  104. package/dist/{utils-ChjYQpjn.mjs → utils-CO8a3B_1.mjs} +70 -26
  105. package/dist/{versioning-D_eBCfNr.d.mts → versioning-DQ4mCvg0.d.mts} +1 -2
  106. package/dist/{websocket-MlYlrYq0.d.mts → websocket-uuWqM6UY.d.mts} +5 -4
  107. package/package.json +7 -7
  108. package/skills/arc-module-publishing/SKILL.md +242 -0
  109. package/skills/arc-module-publishing/references/authoring-modules.md +272 -0
  110. package/skills/arc-module-publishing/references/package-manifest.md +155 -0
  111. package/skills/arc-module-publishing/references/publishing-workflow.md +182 -0
@@ -17,7 +17,7 @@ function createAdapterTemplate(config) {
17
17
 
18
18
  import { createMongooseAdapter } from '@classytic/mongokit/adapter';
19
19
  import { buildCrudSchemasFromModel } from '@classytic/mongokit';
20
- ${ts ? "import type { Model } from 'mongoose';\nimport type { Repository } from '@classytic/mongokit';" : ""}
20
+ ${ts ? "import type { DataAdapter } from '@classytic/repo-core/adapter';\nimport type { Model } from 'mongoose';\nimport type { Repository } from '@classytic/mongokit';" : ""}
21
21
 
22
22
  /**
23
23
  * Create a MongoKit-powered adapter for a resource.
@@ -30,7 +30,10 @@ ${ts ? "import type { Model } from 'mongoose';\nimport type { Repository } from
30
30
  export function createAdapter${ts ? "<TDoc = unknown>" : ""}(
31
31
  model${ts ? ": Model<TDoc>" : ""},
32
32
  repository${ts ? ": Repository<TDoc>" : ""}
33
- ) {
33
+ )${ts ? ": DataAdapter<TDoc>" : ""} {
34
+ // Explicit return type keeps the declaration portable — under pnpm's
35
+ // non-hoisted layout the inferred type references mongokit's internal
36
+ // paths and tsc fails with TS2742/TS2883.
34
37
  return createMongooseAdapter({
35
38
  model,
36
39
  repository,
@@ -484,20 +487,13 @@ ${[
484
487
  ].filter(Boolean).join("\n")}
485
488
  import config from '#config/index.js';
486
489
 
487
- let _auth${ts ? ": ReturnType<typeof betterAuth> | null" : ""} = null;
488
-
489
- /**
490
- * Get the Better Auth instance (lazy singleton)
491
- *
492
- * Must be called AFTER database connection is established.
493
- */
494
- export function getAuth()${ts ? ": ReturnType<typeof betterAuth>" : ""} {
495
- if (process.env.NODE_ENV === 'production' && !process.env.BETTER_AUTH_SECRET) {
496
- throw new Error('BETTER_AUTH_SECRET is required in production (min 32 chars)');
497
- }
498
-
499
- if (!_auth) {
500
- _auth = betterAuth({
490
+ // Factory keeps the CONCRETE betterAuth({...}) instantiation as the source
491
+ // of truth for the instance type — annotating with the wide
492
+ // \`Auth<BetterAuthOptions>\` (aka \`ReturnType<typeof betterAuth>\`) does not
493
+ // typecheck because Better Auth's \`Auth<T>\` is generic over the exact
494
+ // options object.
495
+ function createAuth() {
496
+ return betterAuth({
501
497
  secret: config.betterAuth.secret,
502
498
  baseURL: process.env.BETTER_AUTH_URL || \`http://localhost:\${config.server.port}\`,
503
499
  basePath: '/api/auth',
@@ -545,6 +541,26 @@ ${orgPluginUsage}
545
541
  enabled: process.env.NODE_ENV === 'production',
546
542
  },
547
543
  });
544
+ }
545
+
546
+ let _auth${ts ? ": ReturnType<typeof createAuth> | null" : ""} = null;
547
+
548
+ /**
549
+ * Get the Better Auth instance (lazy singleton)
550
+ *
551
+ * Must be called AFTER database connection is established.
552
+ */
553
+ export function getAuth()${ts ? ": ReturnType<typeof createAuth>" : ""} {
554
+ if (process.env.NODE_ENV === 'production' && !process.env.BETTER_AUTH_SECRET) {
555
+ throw new Error('BETTER_AUTH_SECRET is required in production (min 32 chars)');
556
+ }
557
+
558
+ // Narrow through a local — assigning the module-level \`_auth\` directly
559
+ // would lose the non-null narrowing across the calls below.
560
+ let auth = _auth;
561
+ if (!auth) {
562
+ auth = createAuth();
563
+ _auth = auth;
548
564
  ${useStubs ? `
549
565
  // Register stub Mongoose models for Better Auth's collections so
550
566
  // \`.populate('user')\` / \`ref: 'organization'\` resolve against BA-owned
@@ -552,7 +568,7 @@ ${useStubs ? `
552
568
  registerBetterAuthStubs(mongoose, {${stubPluginsList ? ` plugins: [${stubPluginsList}]` : ""}${stubExtras} });
553
569
  ` : ""} }
554
570
 
555
- return _auth;
571
+ return auth;
556
572
  }
557
573
 
558
574
  export default getAuth;
@@ -692,6 +708,9 @@ export async function getUserProfile(request${ts ? ": FastifyRequest" : ""}, rep
692
708
  try {
693
709
  const requestUser = (request${ts ? " as unknown as { user?: { _id?: string; id?: string } }" : ""}).user;
694
710
  const userId = requestUser?._id || requestUser?.id;
711
+ if (!userId) {
712
+ return reply.code(401).send({ error: 'Authentication required', code: 'arc.unauthorized' });
713
+ }
695
714
  const user = await userRepository.getById(userId);
696
715
 
697
716
  if (!user) {
@@ -712,6 +731,9 @@ export async function updateUserProfile(request${ts ? ": FastifyRequest" : ""},
712
731
  try {
713
732
  const requestUser = (request${ts ? " as unknown as { user?: { _id?: string; id?: string } }" : ""}).user;
714
733
  const userId = requestUser?._id || requestUser?.id;
734
+ if (!userId) {
735
+ return reply.code(401).send({ error: 'Authentication required', code: 'arc.unauthorized' });
736
+ }
715
737
  const updates${ts ? ": Record<string, unknown>" : ""} = { ...request.body${ts ? " as Record<string, unknown>" : ""} };
716
738
 
717
739
  // Prevent updating protected fields — auth-managed only
@@ -840,20 +862,29 @@ function authTestTemplate(config) {
840
862
  */
841
863
 
842
864
  import { describe, it, expect, beforeAll, afterAll } from 'vitest';
843
- ${config.adapter === "mongokit" ? "import mongoose from 'mongoose';\n" : ""}import { createAppInstance } from '../src/app.js';
865
+ ${config.adapter === "mongokit" ? "import mongoose from 'mongoose';\nimport { MongoMemoryServer } from 'mongodb-memory-server';\n" : ""}import { createAppInstance } from '../src/app.js';
844
866
  ${ts ? "import type { FastifyInstance } from 'fastify';\n" : ""}
867
+ // Resources mount under the app's resourcePrefix (see src/app.${ts ? "ts" : "js"}).
868
+ const API = '/api';
869
+
845
870
  describe('Auth', () => {
846
871
  let app${ts ? ": FastifyInstance" : ""};
847
- const testUser = {
872
+ ${config.adapter === "mongokit" ? ` let mongo${ts ? ": MongoMemoryServer | undefined" : ""};\n` : ""} const testUser = {
848
873
  name: 'Test User',
849
874
  email: 'test@example.com',
850
875
  password: 'password123',
851
876
  };
852
877
 
853
878
  beforeAll(async () => {
854
- ${config.adapter === "mongokit" ? ` const testDbUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/${config.name}-test';
855
- await mongoose.connect(testDbUri);
856
- // Clean up test data
879
+ ${config.adapter === "mongokit" ? ` // In-memory MongoDB — no local mongod required. Set MONGODB_URI to use
880
+ // a real instance instead (e.g. a CI service container).
881
+ let uri = process.env.MONGODB_URI;
882
+ if (!uri) {
883
+ mongo = await MongoMemoryServer.create();
884
+ uri = mongo.getUri();
885
+ }
886
+ await mongoose.connect(uri);
887
+ // Clean up test data (matters when MONGODB_URI points at a shared DB)
857
888
  await mongoose.connection.collection('users').deleteMany({ email: testUser.email });
858
889
  ` : ""}
859
890
  app = await createAppInstance();
@@ -862,27 +893,29 @@ ${config.adapter === "mongokit" ? ` const testDbUri = process.env.MONGODB_URI
862
893
 
863
894
  afterAll(async () => {
864
895
  ${config.adapter === "mongokit" ? ` await mongoose.connection.collection('users').deleteMany({ email: testUser.email });
896
+ await app.close();
865
897
  await mongoose.connection.close();
866
- ` : ""} await app.close();
867
- });
898
+ await mongo?.stop();
899
+ ` : ` await app.close();
900
+ `} });
868
901
 
869
902
  describe('POST /auth/register', () => {
870
903
  it('should register a new user', async () => {
871
904
  const response = await app.inject({
872
905
  method: 'POST',
873
- url: '/auth/register',
906
+ url: \`\${API}/auth/register\`,
874
907
  payload: testUser,
875
908
  });
876
909
 
877
910
  expect(response.statusCode).toBe(201);
878
911
  const body = JSON.parse(response.body);
879
- expect(body.success).toBe(true);
912
+ expect(body.message).toBeDefined();
880
913
  });
881
914
 
882
915
  it('should reject duplicate email', async () => {
883
916
  const response = await app.inject({
884
917
  method: 'POST',
885
- url: '/auth/register',
918
+ url: \`\${API}/auth/register\`,
886
919
  payload: testUser,
887
920
  });
888
921
 
@@ -894,13 +927,13 @@ ${config.adapter === "mongokit" ? ` await mongoose.connection.collection('use
894
927
  it('should login with valid credentials', async () => {
895
928
  const response = await app.inject({
896
929
  method: 'POST',
897
- url: '/auth/login',
930
+ url: \`\${API}/auth/login\`,
898
931
  payload: { email: testUser.email, password: testUser.password },
899
932
  });
900
933
 
901
934
  expect(response.statusCode).toBe(200);
902
935
  const body = JSON.parse(response.body);
903
- expect(body.success).toBe(true);
936
+ expect(body.user).toBeDefined();
904
937
  expect(body.accessToken).toBeDefined();
905
938
  expect(body.refreshToken).toBeDefined();
906
939
  });
@@ -908,7 +941,7 @@ ${config.adapter === "mongokit" ? ` await mongoose.connection.collection('use
908
941
  it('should reject invalid credentials', async () => {
909
942
  const response = await app.inject({
910
943
  method: 'POST',
911
- url: '/auth/login',
944
+ url: \`\${API}/auth/login\`,
912
945
  payload: { email: testUser.email, password: 'wrongpassword' },
913
946
  });
914
947
 
@@ -920,7 +953,7 @@ ${config.adapter === "mongokit" ? ` await mongoose.connection.collection('use
920
953
  it('should require authentication', async () => {
921
954
  const response = await app.inject({
922
955
  method: 'GET',
923
- url: '/users/me',
956
+ url: \`\${API}/users/me\`,
924
957
  });
925
958
 
926
959
  expect(response.statusCode).toBe(401);
@@ -955,8 +988,8 @@ ${config.adapter === "mongokit" ? ` await mongoose.connection.collection('use
955
988
  const SCAFFOLD_DEP_VERSIONS = {
956
989
  core: {
957
990
  "@classytic/arc": "^2.20.0",
958
- "@classytic/primitives": "^0.9.1",
959
- "@classytic/repo-core": "^0.7.0",
991
+ "@classytic/primitives": "^0.11.0",
992
+ "@classytic/repo-core": "^0.8.0",
960
993
  "@fastify/cors": "^11.2.0",
961
994
  "@fastify/helmet": "^13.0.2",
962
995
  "@fastify/rate-limit": "^10.3.0",
@@ -976,7 +1009,7 @@ const SCAFFOLD_DEP_VERSIONS = {
976
1009
  },
977
1010
  authBetterAuthApiKey: { "@better-auth/api-key": "^1.6.11" },
978
1011
  adapterMongokit: {
979
- "@classytic/mongokit": "^3.16.1",
1012
+ "@classytic/mongokit": "^3.21.0",
980
1013
  mongoose: "^9.6.2"
981
1014
  },
982
1015
  devCommon: {
@@ -988,7 +1021,7 @@ const SCAFFOLD_DEP_VERSIONS = {
988
1021
  devTypescript: {
989
1022
  "@types/node": "^22.10.0",
990
1023
  tsx: "^4.22.3",
991
- typescript: "^6.0.3"
1024
+ typescript: "^7.0.2"
992
1025
  },
993
1026
  typesJwt: { "@types/bcryptjs": "^3.0.0" }
994
1027
  };
@@ -1061,6 +1094,7 @@ function resolveScaffoldDependencies(config) {
1061
1094
  const devDependencies = { ...SCAFFOLD_DEP_VERSIONS.devCommon };
1062
1095
  if (config.auth === "better-auth") {
1063
1096
  Object.assign(dependencies, SCAFFOLD_DEP_VERSIONS.authBetterAuth);
1097
+ devDependencies["@fastify/jwt"] = SCAFFOLD_DEP_VERSIONS.authJwt["@fastify/jwt"];
1064
1098
  if (config.apiKey) Object.assign(dependencies, SCAFFOLD_DEP_VERSIONS.authBetterAuthApiKey);
1065
1099
  } else {
1066
1100
  Object.assign(dependencies, SCAFFOLD_DEP_VERSIONS.authJwt);
@@ -1159,6 +1193,7 @@ function tsconfigTemplate() {
1159
1193
  declarationMap: true,
1160
1194
  sourceMap: true,
1161
1195
  resolveJsonModule: true,
1196
+ types: ["node"],
1162
1197
  paths: {
1163
1198
  "#shared/*": ["./src/shared/*"],
1164
1199
  "#resources/*": ["./src/resources/*"],
@@ -2053,7 +2088,13 @@ import { describe, it, beforeAll, afterAll } from 'vitest';
2053
2088
  import { createTestApp, expectArc } from '@classytic/arc/testing';
2054
2089
  import type { TestAppContext${ts ? ", TestAuthProvider" : ""} } from '@classytic/arc/testing';
2055
2090
  import exampleResource from '../src/resources/example/example.resource.js';
2056
-
2091
+ ${config.tenant === "multi" ? `
2092
+ // Valid ObjectId hex — the example model types organizationId and createdBy
2093
+ // as ObjectId (createdBy is auto-stamped from the caller's user id).
2094
+ const TEST_ORG_ID = '64f000000000000000000001';
2095
+ const MANAGER_ID = '64f0000000000000000000a1';
2096
+ const STAFF_ID = '64f0000000000000000000a2';
2097
+ ` : ""}
2057
2098
  describe('Example Resource', () => {
2058
2099
  let ctx${ts ? ": TestAppContext" : ""};
2059
2100
  // Narrowed once below — \`authMode: 'jwt'\` guarantees a provider, but
@@ -2068,17 +2109,70 @@ ${config.adapter === "mongokit" ? " connectMongoose: true,\n" : ""} });
2068
2109
 
2069
2110
  if (!ctx.auth) throw new Error('Test auth provider was not configured');
2070
2111
  auth = ctx.auth;
2071
-
2112
+ ${config.tenant === "multi" ? `
2113
+ // Arc's JWT auth builds a MEMBER scope straight from token claims —
2114
+ // \`organizationId\` + \`orgRoles\` land on \`request.scope\`. The org-staff
2115
+ // permission set reads those org roles; the tenant preset filters and
2116
+ // stamps rows by the org id.
2117
+ auth.register('manager', {
2118
+ user: { id: MANAGER_ID, role: 'user', organizationId: TEST_ORG_ID, orgRoles: ['manager'] },
2119
+ orgId: TEST_ORG_ID,
2120
+ });
2121
+ auth.register('staff', {
2122
+ user: { id: STAFF_ID, role: 'user', organizationId: TEST_ORG_ID, orgRoles: ['staff'] },
2123
+ orgId: TEST_ORG_ID,
2124
+ });
2125
+ ` : `
2072
2126
  // Arc's permission engine reads singular user.role — string,
2073
2127
  // comma-separated string, or array all normalise via getUserRoles().
2128
+ // The id must be ObjectId-shaped: the example model's createdBy is an
2129
+ // ObjectId, auto-stamped from the caller's user id.
2074
2130
  auth.register('admin', {
2075
- user: { id: '1', role: 'admin' },
2076
- orgId: 'org-1',
2131
+ user: { id: '64f0000000000000000000ad', role: 'admin' },
2077
2132
  });
2078
- });
2133
+ `} });
2079
2134
 
2080
2135
  afterAll(() => ctx.close());
2136
+ ${config.tenant === "multi" ? `
2137
+ describe('GET /examples', () => {
2138
+ it('should reject unauthenticated listing (org-staff gated)', async () => {
2139
+ const res = await ctx.app.inject({ method: 'GET', url: '/examples' });
2140
+ expectArc(res).unauthorized();
2141
+ });
2081
2142
 
2143
+ it('should list for an org member (rows scoped to their org)', async () => {
2144
+ const res = await ctx.app.inject({
2145
+ method: 'GET',
2146
+ url: '/examples',
2147
+ headers: auth.as('staff').headers,
2148
+ });
2149
+ expectArc(res).ok().paginated();
2150
+ });
2151
+ });
2152
+
2153
+ describe('POST /examples', () => {
2154
+ it('should require authentication', async () => {
2155
+ const res = await ctx.app.inject({
2156
+ method: 'POST',
2157
+ url: '/examples',
2158
+ payload: { name: 'Test Example' },
2159
+ });
2160
+ expectArc(res).unauthorized();
2161
+ });
2162
+
2163
+ it('should create for an org manager — organizationId stamped server-side', async () => {
2164
+ const res = await ctx.app.inject({
2165
+ method: 'POST',
2166
+ url: '/examples',
2167
+ headers: auth.as('manager').headers,
2168
+ // No organizationId in the body — the tenant preset injects it from
2169
+ // the caller's scope (the field is systemManaged in the schema).
2170
+ payload: { name: 'Test Example' },
2171
+ });
2172
+ expectArc(res).ok(201);
2173
+ });
2174
+ });
2175
+ ` : `
2082
2176
  describe('GET /examples', () => {
2083
2177
  it('should return a list of examples (public)', async () => {
2084
2178
  const res = await ctx.app.inject({ method: 'GET', url: '/examples' });
@@ -2103,9 +2197,10 @@ ${config.adapter === "mongokit" ? " connectMongoose: true,\n" : ""} });
2103
2197
  headers: auth.as('admin').headers,
2104
2198
  payload: { name: 'Test Example' },
2105
2199
  });
2106
- expectArc(res).ok();
2200
+ expectArc(res).ok(201);
2107
2201
  });
2108
2202
  });
2203
+ `}
2109
2204
 
2110
2205
  // Add more tests as needed:
2111
2206
  // - GET /examples/:id (expectArc(res).ok().hasData({ name: '...' }))
@@ -2495,113 +2590,35 @@ function flexibleMultiTenantPresetTemplate(config) {
2495
2590
  return `/**
2496
2591
  * Flexible Multi-Tenant Preset
2497
2592
  *
2498
- * Smarter tenant filtering that works with public + authenticated routes.
2593
+ * Thin wrapper around arc's built-in 'multiTenantPreset' with public reads:
2594
+ *
2595
+ * - list/get WITHOUT org context → allowed through unfiltered (public data)
2596
+ * - org context present → rows filtered + stamped to the caller's org
2597
+ * - elevated (platform admin) → unfiltered, cross-tenant
2598
+ * - create/update fail closed without org context, and update overwrites any
2599
+ * client-supplied tenant field (no cross-tenant document hops)
2499
2600
  *
2500
- * Philosophy:
2501
- * - No org scope → No filtering (public data, all orgs)
2502
- * - Org scope present → Filter by org
2503
- * - Elevated scope → No filter (platform admin sees all)
2601
+ * The built-in preset also registers 'systemManaged' field rules for the
2602
+ * tenant field, so generated request schemas never demand it in the body —
2603
+ * the server stamps it from the caller's scope instead. Don't hand-roll
2604
+ * tenant middleware: without those field rules Fastify's validation rejects
2605
+ * creates before injection can run.
2504
2606
  *
2505
- * Uses request.scope (RequestScope) from Arc's scope system.
2607
+ * Want members-only reads? Drop 'allowPublic' below, or use
2608
+ * 'multiTenantPreset({ tenantField })' directly on the resource.
2506
2609
  */
2507
2610
  ${ts ? `
2508
- import { getOrgId, isElevated, isMember } from '@classytic/arc/scope';
2509
- import type { RequestScope } from '@classytic/arc/scope';
2611
+ import { multiTenantPreset } from '@classytic/arc/presets/tenant';
2510
2612
 
2511
2613
  interface FlexibleMultiTenantOptions {
2512
2614
  tenantField?: string;
2513
2615
  }
2514
-
2515
- interface PresetMiddlewares {
2516
- list: ((request: any, reply: any) => Promise<void>)[];
2517
- get: ((request: any, reply: any) => Promise<void>)[];
2518
- create: ((request: any, reply: any) => Promise<void>)[];
2519
- update: ((request: any, reply: any) => Promise<void>)[];
2520
- delete: ((request: any, reply: any) => Promise<void>)[];
2521
- }
2522
-
2523
- interface Preset {
2524
- [key: string]: unknown;
2525
- name: string;
2526
- middlewares: PresetMiddlewares;
2527
- }
2528
2616
  ` : `
2529
- const { getOrgId, isElevated, isMember } = require('@classytic/arc/scope');
2617
+ const { multiTenantPreset } = require('@classytic/arc/presets/tenant');
2530
2618
  `}
2531
- /**
2532
- * Create flexible tenant filter middleware.
2533
- * Only filters when org context is present.
2534
- */
2535
- function createFlexibleTenantFilter(tenantField${ts ? ": string" : ""}) {
2536
- return async (request${ts ? ": any" : ""}, reply${ts ? ": any" : ""}) => {
2537
- const scope${ts ? ": RequestScope" : ""} = request.scope ?? { kind: 'public' };
2538
-
2539
- // Elevated scope — platform admin sees all, no filter
2540
- if (isElevated(scope)) {
2541
- request.log?.debug?.({ msg: 'Elevated scope — no tenant filter' });
2542
- return;
2543
- }
2544
-
2545
- // Member scope — filter by org
2546
- if (isMember(scope)) {
2547
- request.query = request.query ?? {};
2548
- request.query._policyFilters = {
2549
- ...(request.query._policyFilters ?? {}),
2550
- [tenantField]: scope.organizationId,
2551
- };
2552
- request.log?.debug?.({ msg: 'Tenant filter applied', orgId: scope.organizationId, tenantField });
2553
- return;
2554
- }
2555
-
2556
- // Public / authenticated — no org context, show all data (public routes)
2557
- request.log?.debug?.({ msg: 'No org context — showing all data' });
2558
- };
2559
- }
2560
-
2561
- /**
2562
- * Create tenant injection middleware.
2563
- * Injects tenant ID into request body on create.
2564
- */
2565
- function createTenantInjection(tenantField${ts ? ": string" : ""}) {
2566
- return async (request${ts ? ": any" : ""}, reply${ts ? ": any" : ""}) => {
2567
- const scope${ts ? ": RequestScope" : ""} = request.scope ?? { kind: 'public' };
2568
- const orgId = getOrgId(scope);
2569
-
2570
- // Fail-closed: Require orgId for create operations
2571
- if (!orgId) {
2572
- return reply.code(403).send({
2573
- error: 'Organization context required to create resources',
2574
- code: 'arc.org_required',
2575
- });
2576
- }
2577
-
2578
- if (request.body) {
2579
- request.body[tenantField] = orgId;
2580
- }
2581
- };
2582
- }
2583
-
2584
- /**
2585
- * Flexible Multi-Tenant Preset
2586
- *
2587
- * @param options.tenantField - Field name in database (default: 'organizationId')
2588
- */
2589
- export function flexibleMultiTenantPreset(options${ts ? ": FlexibleMultiTenantOptions = {}" : " = {}"})${ts ? ": Preset" : ""} {
2619
+ export function flexibleMultiTenantPreset(options${ts ? ": FlexibleMultiTenantOptions = {}" : " = {}"}) {
2590
2620
  const { tenantField = 'organizationId' } = options;
2591
-
2592
- const tenantFilter = createFlexibleTenantFilter(tenantField);
2593
- const tenantInjection = createTenantInjection(tenantField);
2594
-
2595
- return {
2596
- name: 'flexibleMultiTenant',
2597
- middlewares: {
2598
- list: [tenantFilter],
2599
- get: [tenantFilter],
2600
- create: [tenantInjection],
2601
- update: [tenantFilter],
2602
- delete: [tenantFilter],
2603
- },
2604
- };
2621
+ return multiTenantPreset({ tenantField, allowPublic: ['list', 'get'] });
2605
2622
  }
2606
2623
 
2607
2624
  export default flexibleMultiTenantPreset;
@@ -1,7 +1,6 @@
1
- import { n as DomainEvent } from "../EventTransport-IEn7Tn-Q.mjs";
2
- import { c as WebSocketClient, l as WebSocketMessage } from "../websocket-MlYlrYq0.mjs";
1
+ import { n as DomainEvent } from "../EventTransport-QmY7A258.mjs";
2
+ import { c as WebSocketClient, l as WebSocketMessage } from "../websocket-uuWqM6UY.mjs";
3
3
  import { FastifyPluginAsync, FastifyRequest } from "fastify";
4
-
5
4
  //#region src/integrations/event-gateway.d.ts
6
5
  interface EventGatewayOptions {
7
6
  /** Require auth for all real-time connections (default: true) */
@@ -1,7 +1,7 @@
1
- import { c as WebSocketClient, l as WebSocketMessage, u as WebSocketPluginOptions } from "../websocket-MlYlrYq0.mjs";
1
+ import { c as WebSocketClient, l as WebSocketMessage, u as WebSocketPluginOptions } from "../websocket-uuWqM6UY.mjs";
2
2
  import { EventGatewayOptions } from "./event-gateway.mjs";
3
3
  import { JobDefinition, JobDispatchOptions, JobDispatcher, JobMeta, JobsPluginOptions, QueueStats } from "./jobs.mjs";
4
- import { c as McpAuthResult, d as PromptDefinition, g as ToolDefinition, h as ToolContext, l as McpPluginOptions, m as ToolAnnotations, n as CallToolResult, o as CrudOperation, r as CreateMcpServerConfig, t as BetterAuthHandler, u as McpResourceConfig } from "../types-C9b2HR5W.mjs";
4
+ import { c as McpAuthResult, d as PromptDefinition, g as ToolDefinition, h as ToolContext, l as McpPluginOptions, m as ToolAnnotations, n as CallToolResult, o as CrudOperation, r as CreateMcpServerConfig, t as BetterAuthHandler, u as McpResourceConfig } from "../types-DRj-Ut4n.mjs";
5
5
  import { StreamlinePluginOptions, WorkflowLike, WorkflowRunLike } from "./streamline.mjs";
6
6
  import { WebhookDeliveryRecord, WebhookManager, WebhookPluginOptions, WebhookStore, WebhookSubscription } from "./webhooks.mjs";
7
7
  export type { BetterAuthHandler, CallToolResult, CreateMcpServerConfig, CrudOperation, EventGatewayOptions, JobDefinition, JobDispatchOptions, JobDispatcher, JobMeta, JobsPluginOptions, McpAuthResult, McpPluginOptions, McpResourceConfig, PromptDefinition, QueueStats, StreamlinePluginOptions, ToolAnnotations, ToolContext, ToolDefinition, WebSocketClient, WebSocketMessage, WebSocketPluginOptions, WebhookDeliveryRecord, WebhookManager, WebhookPluginOptions, WebhookStore, WebhookSubscription, WorkflowLike, WorkflowRunLike };
@@ -1,5 +1,4 @@
1
1
  import { FastifyPluginAsync } from "fastify";
2
-
3
2
  //#region src/integrations/jobs.d.ts
4
3
  /** Repeat schedule — cron pattern or fixed interval. Explicit timezone is required. */
5
4
  interface JobRepeatOptions {
@@ -1,11 +1,10 @@
1
- import { V as ResourceDefinition, bt as IControllerResponse, xt as IRequestContext } from "../../index-_--GyzcO.mjs";
2
- import { i as RequestScope } from "../../types-LNebCoJx.mjs";
3
- import { c as PermissionCheck } from "../../fields-C7ahdGYf.mjs";
4
- import { a as CrudDescriptionOverride, c as McpAuthResult, d as PromptDefinition, f as PromptResult, g as ToolDefinition, h as ToolContext, i as CrudDescriptionMeta, l as McpPluginOptions, m as ToolAnnotations, n as CallToolResult, o as CrudOperation, p as SessionEntry, r as CreateMcpServerConfig, s as McpAuthResolver, t as BetterAuthHandler, u as McpResourceConfig } from "../../types-C9b2HR5W.mjs";
1
+ import { V as ResourceDefinition, bt as IControllerResponse, xt as IRequestContext } from "../../index-Dtrj6XZk.mjs";
2
+ import { i as RequestScope } from "../../types-DH0GupI7.mjs";
3
+ import { c as PermissionCheck } from "../../fields-BGqrAa_u.mjs";
4
+ import { a as CrudDescriptionOverride, c as McpAuthResult, d as PromptDefinition, f as PromptResult, g as ToolDefinition, h as ToolContext, i as CrudDescriptionMeta, l as McpPluginOptions, m as ToolAnnotations, n as CallToolResult, o as CrudOperation, p as SessionEntry, r as CreateMcpServerConfig, s as McpAuthResolver, t as BetterAuthHandler, u as McpResourceConfig } from "../../types-DRj-Ut4n.mjs";
5
5
  import { ErrorContract } from "@classytic/repo-core/errors";
6
6
  import { FastifyPluginAsync } from "fastify";
7
7
  import { z } from "zod";
8
-
9
8
  //#region src/integrations/mcp/aiSdkBridge.d.ts
10
9
  /** Minimal AI SDK tool shape we need to invoke. */
11
10
  interface AiSdkExecutable {
@@ -170,7 +169,7 @@ interface DefinePromptConfig<TArgs extends Record<string, z.ZodTypeAny>> {
170
169
  title?: string;
171
170
  /** Flat Zod shape for prompt arguments */
172
171
  args?: TArgs;
173
- handler: (args: { [K in keyof TArgs]: z.infer<TArgs[K]> }) => PromptResult;
172
+ handler: (args: { [K in keyof TArgs]: z.infer<TArgs[K]>; }) => PromptResult;
174
173
  }
175
174
  /**
176
175
  * Define a type-safe MCP prompt.
@@ -197,7 +196,7 @@ interface DefineToolConfig<TInput extends Record<string, z.ZodTypeAny>, TSession
197
196
  /** Flat Zod shape for structured output */
198
197
  output?: Record<string, z.ZodTypeAny>;
199
198
  annotations?: ToolAnnotations;
200
- handler: (input: { [K in keyof TInput]: z.infer<TInput[K]> }, ctx: ToolContext<TSession>) => Promise<CallToolResult>;
199
+ handler: (input: { [K in keyof TInput]: z.infer<TInput[K]>; }, ctx: ToolContext<TSession>) => Promise<CallToolResult>;
201
200
  }
202
201
  /**
203
202
  * Define a type-safe MCP tool.
@@ -1,4 +1,4 @@
1
- import { a as defaultCrudDescription, c as mcpHandlerAdapter, d as toCallToolResult, f as toCallToolSuccess, h as resolveToolCollisions, i as fieldRulesToZod, l as permissionDeniedResult, m as createMcpServer, n as mcpPlugin, o as resolveCrudDescription, p as buildRequestContext, r as resourceToTools, s as invokeController, t as filterResourcesForMcp, u as toCallToolError } from "../../mcpPlugin-CuW6L_uD.mjs";
1
+ import { a as defaultCrudDescription, c as mcpHandlerAdapter, d as toCallToolResult, f as toCallToolSuccess, h as resolveToolCollisions, i as fieldRulesToZod, l as permissionDeniedResult, m as createMcpServer, n as mcpPlugin, o as resolveCrudDescription, p as buildRequestContext, r as resourceToTools, s as invokeController, t as filterResourcesForMcp, u as toCallToolError } from "../../mcpPlugin-B9XjjNVG.mjs";
2
2
  //#region src/integrations/mcp/defineTool.ts
3
3
  /**
4
4
  * Define a type-safe MCP tool.
@@ -1,5 +1,4 @@
1
- import { c as McpAuthResult, l as McpPluginOptions } from "../../types-C9b2HR5W.mjs";
2
-
1
+ import { c as McpAuthResult, l as McpPluginOptions } from "../../types-DRj-Ut4n.mjs";
3
2
  //#region src/integrations/mcp/testing.d.ts
4
3
  interface TestMcpClientOptions {
5
4
  /** MCP plugin options (resources, overrides, etc.) — same as mcpPlugin config */
@@ -1,4 +1,4 @@
1
- import { m as createMcpServer, r as resourceToTools, t as filterResourcesForMcp } from "../../mcpPlugin-CuW6L_uD.mjs";
1
+ import { m as createMcpServer, r as resourceToTools, t as filterResourcesForMcp } from "../../mcpPlugin-B9XjjNVG.mjs";
2
2
  //#region src/integrations/mcp/testing.ts
3
3
  /**
4
4
  * @classytic/arc/mcp/testing — MCP Test Utilities
@@ -1,5 +1,4 @@
1
1
  import { FastifyPluginAsync, FastifyRequest } from "fastify";
2
-
3
2
  //#region src/integrations/streamline.d.ts
4
3
  /**
5
4
  * Start options — matches @classytic/streamline v2.3+ `StartOptions`.
@@ -53,7 +52,8 @@ interface WorkflowLike {
53
52
  eventBus: {
54
53
  on(event: string, listener: (...args: unknown[]) => void): void;
55
54
  off(event: string, listener: (...args: unknown[]) => void): void;
56
- }; /** Repository — used by the list-runs endpoint to query workflow_runs. */
55
+ };
56
+ /** Repository — used by the list-runs endpoint to query workflow_runs. */
57
57
  repository?: {
58
58
  getAll(params: Record<string, unknown>, options?: Record<string, unknown>): Promise<unknown>;
59
59
  /**
@@ -1,5 +1,4 @@
1
1
  import { FastifyPluginAsync } from "fastify";
2
-
3
2
  //#region src/integrations/webhooks.d.ts
4
3
  interface WebhookSubscription {
5
4
  /** Unique subscription ID */
@@ -1,5 +1,4 @@
1
- import { c as SerializedEntry, i as Principal, n as FencedWriteOutcome, s as PushRefStore, t as EnvelopeSnapshot } from "../pushref-registry-DnCqL3aa.mjs";
2
-
1
+ import { c as SerializedEntry, i as Principal, n as FencedWriteOutcome, s as PushRefStore, t as EnvelopeSnapshot } from "../pushref-registry-BbcnpNTm.mjs";
3
2
  //#region src/integrations/websocket-pushref-redis.d.ts
4
3
  /**
5
4
  * Minimal `ioredis` shape — typed structurally so this file doesn't
@@ -1,5 +1,4 @@
1
- import { f as WebSocketAdapter } from "../websocket-MlYlrYq0.mjs";
2
-
1
+ import { f as WebSocketAdapter } from "../websocket-uuWqM6UY.mjs";
3
2
  //#region src/integrations/websocket-redis.d.ts
4
3
  interface RedisLike {
5
4
  publish(channel: string, message: string): Promise<number>;
@@ -1,3 +1,3 @@
1
- import { a as GeoRoomOptions, c as WebSocketClient, d as LocalWebSocketAdapter, f as WebSocketAdapter, i as GeoRoom, l as WebSocketMessage, n as GeoPoint, o as RoomManager, r as GeoPublishOpts, s as AuthResult, t as websocketPlugin, u as WebSocketPluginOptions } from "../websocket-MlYlrYq0.mjs";
2
- import { a as PushRefRegistry, c as SerializedEntry, i as Principal, o as PushRefRegistryOptions, r as MemoryPushRefStore, s as PushRefStore } from "../pushref-registry-DnCqL3aa.mjs";
1
+ import { a as GeoRoomOptions, c as WebSocketClient, d as LocalWebSocketAdapter, f as WebSocketAdapter, i as GeoRoom, l as WebSocketMessage, n as GeoPoint, o as RoomManager, r as GeoPublishOpts, s as AuthResult, t as websocketPlugin, u as WebSocketPluginOptions } from "../websocket-uuWqM6UY.mjs";
2
+ import { a as PushRefRegistry, c as SerializedEntry, i as Principal, o as PushRefRegistryOptions, r as MemoryPushRefStore, s as PushRefStore } from "../pushref-registry-BbcnpNTm.mjs";
3
3
  export { type AuthResult, type GeoPoint, type GeoPublishOpts, GeoRoom, type GeoRoomOptions, LocalWebSocketAdapter, MemoryPushRefStore, type Principal, PushRefRegistry, type PushRefRegistryOptions, type PushRefStore, RoomManager, type SerializedEntry, type WebSocketAdapter, type WebSocketClient, type WebSocketMessage, type WebSocketPluginOptions, websocketPlugin };
@@ -59,10 +59,11 @@ var loadResources_exports = /* @__PURE__ */ __exportAll({ loadResources: () => l
59
59
  * ```
60
60
  */
61
61
  async function loadResources(dir, options = {}) {
62
- const { suffix = ".resource", recursive = true, exclude, include } = options;
62
+ const { suffix = ".resource", recursive = true, exclude, include, filter } = options;
63
63
  const absDir = resolve(dir.startsWith("file://") ? dirname(fileURLToPath(dir)) : dir);
64
- const files = await collectFiles(absDir, new RegExp(`${escapeRegex(suffix)}\\.(ts|js|mts|mjs)$`), recursive);
65
- files.sort();
64
+ const discovered = await collectFiles(absDir, new RegExp(`${escapeRegex(suffix)}\\.(ts|js|mts|mjs)$`), recursive);
65
+ discovered.sort();
66
+ const files = filter ? discovered.filter((f) => filter(f)) : discovered;
66
67
  const includeSet = include ? new Set(include) : null;
67
68
  const excludeSet = exclude ? new Set(exclude) : null;
68
69
  const skipped = [];
@@ -1,9 +1,9 @@
1
1
  import { p as isArcError, t as ArcError } from "./errors-DkO8Nk6-.mjs";
2
2
  import { c as getOrgId, g as getUserId } from "./types-bHblZ9YN.mjs";
3
- import { t as BaseController } from "./BaseController-D-MHZ27_.mjs";
4
- import { N as normalizePermissionResult } from "./permissions-CgbOLJZG.mjs";
3
+ import { t as BaseController } from "./BaseController-BnLYgE9A.mjs";
4
+ import { P as normalizePermissionResult } from "./permissions-BJMuKMl7.mjs";
5
5
  import { t as executePipeline } from "./pipe-BOLmGIxa.mjs";
6
- import { i as resolvePipelineSteps, t as resolveActionPermission } from "./actionPermissions-C9RMiIpc.mjs";
6
+ import { i as resolvePipelineSteps, t as resolveActionPermission } from "./actionPermissions-ze70xXJi.mjs";
7
7
  import { n as validateAggregations } from "./validate-C2mGgSXg.mjs";
8
8
  import { n as executeAggregation } from "./buildHandler-D_2ULWxZ.mjs";
9
9
  import { t as pluralize } from "./pluralize-BhrgwZb9.mjs";