swallowkit 1.0.0-beta.13 → 1.0.0-beta.15

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 (59) hide show
  1. package/README.ja.md +2 -0
  2. package/README.md +2 -0
  3. package/dist/cli/commands/add-auth.d.ts +10 -0
  4. package/dist/cli/commands/add-auth.d.ts.map +1 -0
  5. package/dist/cli/commands/add-auth.js +365 -0
  6. package/dist/cli/commands/add-auth.js.map +1 -0
  7. package/dist/cli/commands/dev.js +34 -3
  8. package/dist/cli/commands/dev.js.map +1 -1
  9. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  10. package/dist/cli/commands/scaffold.js +145 -10
  11. package/dist/cli/commands/scaffold.js.map +1 -1
  12. package/dist/cli/index.js +10 -0
  13. package/dist/cli/index.js.map +1 -1
  14. package/dist/core/config.d.ts +5 -1
  15. package/dist/core/config.d.ts.map +1 -1
  16. package/dist/core/config.js +12 -1
  17. package/dist/core/config.js.map +1 -1
  18. package/dist/core/mock/connector-mock-server.d.ts +34 -0
  19. package/dist/core/mock/connector-mock-server.d.ts.map +1 -1
  20. package/dist/core/mock/connector-mock-server.js +192 -0
  21. package/dist/core/mock/connector-mock-server.js.map +1 -1
  22. package/dist/core/scaffold/auth-generator.d.ts +38 -0
  23. package/dist/core/scaffold/auth-generator.d.ts.map +1 -0
  24. package/dist/core/scaffold/auth-generator.js +1246 -0
  25. package/dist/core/scaffold/auth-generator.js.map +1 -0
  26. package/dist/core/scaffold/connector-functions-generator.d.ts +3 -3
  27. package/dist/core/scaffold/connector-functions-generator.d.ts.map +1 -1
  28. package/dist/core/scaffold/connector-functions-generator.js +37 -18
  29. package/dist/core/scaffold/connector-functions-generator.js.map +1 -1
  30. package/dist/core/scaffold/functions-generator.d.ts +2 -1
  31. package/dist/core/scaffold/functions-generator.d.ts.map +1 -1
  32. package/dist/core/scaffold/functions-generator.js +21 -12
  33. package/dist/core/scaffold/functions-generator.js.map +1 -1
  34. package/dist/core/scaffold/model-parser.d.ts +7 -1
  35. package/dist/core/scaffold/model-parser.d.ts.map +1 -1
  36. package/dist/core/scaffold/model-parser.js +45 -0
  37. package/dist/core/scaffold/model-parser.js.map +1 -1
  38. package/dist/core/scaffold/ui-generator.d.ts +10 -4
  39. package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
  40. package/dist/core/scaffold/ui-generator.js +120 -12
  41. package/dist/core/scaffold/ui-generator.js.map +1 -1
  42. package/dist/types/index.d.ts +30 -0
  43. package/dist/types/index.d.ts.map +1 -1
  44. package/package.json +4 -2
  45. package/src/__tests__/__snapshots__/ui-generator.test.ts.snap +9 -1
  46. package/src/__tests__/auth.test.ts +654 -0
  47. package/src/__tests__/connector-mock-server.test.ts +189 -2
  48. package/src/cli/commands/add-auth.ts +413 -0
  49. package/src/cli/commands/dev.ts +35 -4
  50. package/src/cli/commands/scaffold.ts +165 -11
  51. package/src/cli/index.ts +11 -0
  52. package/src/core/config.ts +13 -2
  53. package/src/core/mock/connector-mock-server.ts +248 -0
  54. package/src/core/scaffold/auth-generator.ts +1286 -0
  55. package/src/core/scaffold/connector-functions-generator.ts +42 -18
  56. package/src/core/scaffold/functions-generator.ts +23 -12
  57. package/src/core/scaffold/model-parser.ts +50 -1
  58. package/src/core/scaffold/ui-generator.ts +132 -12
  59. package/src/types/index.ts +42 -0
@@ -6,7 +6,7 @@
6
6
  import * as fs from "fs";
7
7
  import * as path from "path";
8
8
  import { spawn, spawnSync } from "child_process";
9
- import { getBackendLanguage, getConnectorDefinition, ensureSwallowKitProject } from "../../core/config";
9
+ import { getBackendLanguage, getConnectorDefinition, getAuthConfig, ensureSwallowKitProject } from "../../core/config";
10
10
  import { ModelInfo, parseModelFile, toKebabCase, toPascalCase, toCamelCase } from "../../core/scaffold/model-parser";
11
11
  import {
12
12
  generateCSharpAzureFunctionsCRUD,
@@ -30,6 +30,7 @@ import {
30
30
  generateFormComponent,
31
31
  generateNewPage,
32
32
  generateEditPage,
33
+ UIAuthOptions,
33
34
  } from "../../core/scaffold/ui-generator";
34
35
  import { detectFromProject, getCommands } from "../../utils/package-manager";
35
36
  import {
@@ -38,6 +39,8 @@ import {
38
39
  ApiConnectorConfig,
39
40
  RdbModelConnectorConfig,
40
41
  ApiModelConnectorConfig,
42
+ ModelAuthPolicy,
43
+ AuthConfig,
41
44
  } from "../../types";
42
45
 
43
46
  interface ScaffoldOptions {
@@ -103,6 +106,8 @@ export async function scaffoldCommand(options: ScaffoldOptions) {
103
106
  // 6. Generate Azure Functions code
104
107
  if (isConnectorModel) {
105
108
  await generateConnectorFunctionsCode(modelInfo, functionsDir, sharedPackageName, backendLanguage);
109
+ // 6b. Install connector driver dependencies
110
+ await installConnectorDriverDependencies(modelInfo, functionsDir, backendLanguage);
106
111
  } else {
107
112
  await generateFunctionsCode(modelInfo, functionsDir, sharedPackageName, backendLanguage);
108
113
 
@@ -126,7 +131,13 @@ export async function scaffoldCommand(options: ScaffoldOptions) {
126
131
 
127
132
  // 9. Generate UI components (unless --api-only)
128
133
  if (!options.apiOnly) {
129
- await generateUIComponents(modelInfo, sharedPackageName);
134
+ const uiAuthConfig = getAuthConfig();
135
+ const uiAuthPolicy = resolveAuthPolicy(modelInfo, uiAuthConfig);
136
+ const uiAuthOptions: UIAuthOptions | undefined =
137
+ uiAuthPolicy && uiAuthConfig && uiAuthConfig.provider !== 'none'
138
+ ? { authPolicy: uiAuthPolicy }
139
+ : undefined;
140
+ await generateUIComponents(modelInfo, sharedPackageName, uiAuthOptions);
130
141
  await updateNavigationMenu(modelInfo);
131
142
  }
132
143
 
@@ -153,6 +164,33 @@ export async function scaffoldCommand(options: ScaffoldOptions) {
153
164
  }
154
165
  }
155
166
 
167
+ /**
168
+ * モデルの authPolicy と auth config から実効的な認可ポリシーを解決
169
+ * - モデルに authPolicy があればそれを使用
170
+ * - なければ auth.authorization.defaultPolicy に従う
171
+ * - auth 設定がなければ undefined(ガードなし)
172
+ */
173
+ function resolveAuthPolicy(modelInfo: ModelInfo, authConfig: AuthConfig | undefined): ModelAuthPolicy | undefined {
174
+ // モデルに明示的な authPolicy がある場合はそれを使用
175
+ if (modelInfo.authPolicy) {
176
+ return modelInfo.authPolicy;
177
+ }
178
+
179
+ // auth 設定がない場合はガードなし
180
+ if (!authConfig || authConfig.provider === 'none') {
181
+ return undefined;
182
+ }
183
+
184
+ // defaultPolicy が 'authenticated' なら認証のみ(ロール指定なし)のポリシーを返す
185
+ const defaultPolicy = authConfig.authorization?.defaultPolicy ?? 'authenticated';
186
+ if (defaultPolicy === 'authenticated') {
187
+ return {}; // 空のポリシー = 認証のみ、ロール制限なし
188
+ }
189
+
190
+ // defaultPolicy が 'anonymous' なら認証不要
191
+ return undefined;
192
+ }
193
+
156
194
  /**
157
195
  * モデルファイルのパスを解決
158
196
  */
@@ -236,7 +274,17 @@ async function generateCallFunctionHelper(): Promise<void> {
236
274
  fs.mkdirSync(helperDir, { recursive: true });
237
275
  }
238
276
 
239
- const helperCode = generateBFFCallFunction();
277
+ // auth 設定がある場合は Authorization ヘッダー転送版を生成
278
+ const authConfig = getAuthConfig();
279
+ const hasAuth = authConfig && authConfig.provider !== 'none';
280
+ let helperCode: string;
281
+ if (hasAuth) {
282
+ const { generateBFFCallFunctionWithAuth } = await import("../../core/scaffold/auth-generator");
283
+ helperCode = generateBFFCallFunctionWithAuth();
284
+ } else {
285
+ helperCode = generateBFFCallFunction();
286
+ }
287
+
240
288
  fs.writeFileSync(helperPath, helperCode, "utf-8");
241
289
  console.log(`✅ Created: ${helperPath}`);
242
290
  }
@@ -252,6 +300,13 @@ async function generateFunctionsCode(
252
300
  ): Promise<void> {
253
301
  console.log("\n🔨 Generating Azure Functions CRUD code...");
254
302
 
303
+ // Resolve auth policy: model-level authPolicy or global defaultPolicy
304
+ const authConfig = getAuthConfig();
305
+ const authPolicy = resolveAuthPolicy(modelInfo, authConfig);
306
+ if (authPolicy) {
307
+ console.log(`🔐 Auth policy detected: ${JSON.stringify(authPolicy)}`);
308
+ }
309
+
255
310
  const modelKebab = toKebabCase(modelInfo.name);
256
311
  if (backendLanguage === "typescript") {
257
312
  const functionFilePath = path.join(
@@ -266,7 +321,7 @@ async function generateFunctionsCode(
266
321
  fs.mkdirSync(functionDir, { recursive: true });
267
322
  }
268
323
 
269
- const code = generateCompactAzureFunctionsCRUD(modelInfo, sharedPackageName);
324
+ const code = generateCompactAzureFunctionsCRUD(modelInfo, sharedPackageName, authPolicy);
270
325
  fs.writeFileSync(functionFilePath, code, "utf-8");
271
326
  console.log(`✅ Created: ${functionFilePath}`);
272
327
  return;
@@ -316,6 +371,13 @@ async function generateConnectorFunctionsCode(
316
371
  );
317
372
  }
318
373
 
374
+ // Resolve auth policy (same logic as Cosmos model scaffolding)
375
+ const authConfig = getAuthConfig();
376
+ const authPolicy = resolveAuthPolicy(modelInfo, authConfig);
377
+ if (authPolicy) {
378
+ console.log(`🔐 Auth policy detected: ${JSON.stringify(authPolicy)}`);
379
+ }
380
+
319
381
  const modelKebab = toKebabCase(modelInfo.name);
320
382
 
321
383
  if (backendLanguage === "typescript") {
@@ -332,13 +394,15 @@ async function generateConnectorFunctionsCode(
332
394
  code = generateRdbConnectorFunctionTS(
333
395
  modelInfo, sharedPackageName,
334
396
  connectorDef as RdbConnectorConfig,
335
- connectorConfig as RdbModelConnectorConfig
397
+ connectorConfig as RdbModelConnectorConfig,
398
+ authPolicy
336
399
  );
337
400
  } else {
338
401
  code = generateApiConnectorFunctionTS(
339
402
  modelInfo, sharedPackageName,
340
403
  connectorDef as ApiConnectorConfig,
341
- connectorConfig as ApiModelConnectorConfig
404
+ connectorConfig as ApiModelConnectorConfig,
405
+ authPolicy
342
406
  );
343
407
  }
344
408
 
@@ -401,6 +465,96 @@ async function generateConnectorFunctionsCode(
401
465
  console.log(`✅ Created: ${blueprintPath}`);
402
466
  }
403
467
 
468
+ /**
469
+ * コネクタモデルの scaffold 後に、生成コードが必要とする RDB/API ドライバを
470
+ * functions ディレクトリにインストールする。
471
+ */
472
+ async function installConnectorDriverDependencies(
473
+ modelInfo: ModelInfo,
474
+ functionsDir: string,
475
+ backendLanguage: BackendLanguage
476
+ ): Promise<void> {
477
+ const connectorConfig = modelInfo.connectorConfig!;
478
+ const connectorDef = getConnectorDefinition(connectorConfig.connector);
479
+ if (!connectorDef || connectorDef.type !== "rdb") return;
480
+
481
+ const rdbDef = connectorDef as RdbConnectorConfig;
482
+ const functionsPath = path.join(process.cwd(), functionsDir);
483
+
484
+ if (backendLanguage === "typescript") {
485
+ const driverMap: Record<string, { deps: string[]; devDeps: string[] }> = {
486
+ mysql: { deps: ["mysql2"], devDeps: [] },
487
+ postgres: { deps: ["pg"], devDeps: ["@types/pg"] },
488
+ sqlserver: { deps: ["mssql"], devDeps: [] },
489
+ };
490
+ const entry = driverMap[rdbDef.provider];
491
+ if (!entry) return;
492
+
493
+ // package.json を読んで、既にインストール済みならスキップ
494
+ const pkgJsonPath = path.join(functionsPath, "package.json");
495
+ if (fs.existsSync(pkgJsonPath)) {
496
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
497
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
498
+ const missingDeps = entry.deps.filter(d => !allDeps[d]);
499
+ const missingDevDeps = entry.devDeps.filter(d => !allDeps[d]);
500
+ if (missingDeps.length === 0 && missingDevDeps.length === 0) return;
501
+ }
502
+
503
+ console.log(`\n📦 Installing ${rdbDef.provider} driver dependencies...`);
504
+ const pm = detectFromProject(functionsPath);
505
+ const cmds = getCommands(pm);
506
+
507
+ const { spawnSync } = require("child_process");
508
+ if (entry.deps.length > 0) {
509
+ spawnSync(cmds.name, [pm === "pnpm" ? "add" : "install", ...entry.deps], {
510
+ cwd: functionsPath, stdio: "inherit", shell: true,
511
+ });
512
+ }
513
+ if (entry.devDeps.length > 0) {
514
+ spawnSync(cmds.name, [pm === "pnpm" ? "add" : "install", "-D", ...entry.devDeps], {
515
+ cwd: functionsPath, stdio: "inherit", shell: true,
516
+ });
517
+ }
518
+ console.log(`✅ ${rdbDef.provider} driver installed`);
519
+ return;
520
+ }
521
+
522
+ if (backendLanguage === "csharp") {
523
+ const nugetMap: Record<string, string> = {
524
+ mysql: "MySqlConnector",
525
+ postgres: "Npgsql",
526
+ sqlserver: "Microsoft.Data.SqlClient",
527
+ };
528
+ const pkg = nugetMap[rdbDef.provider];
529
+ if (!pkg) return;
530
+ console.log(`\n📦 Installing ${rdbDef.provider} NuGet package...`);
531
+ const { spawnSync } = require("child_process");
532
+ spawnSync("dotnet", ["add", path.join(functionsPath, "functions.csproj"), "package", pkg], {
533
+ cwd: functionsPath, stdio: "inherit", shell: true,
534
+ });
535
+ console.log(`✅ ${pkg} installed`);
536
+ return;
537
+ }
538
+
539
+ // Python — requirements.txt に追記
540
+ const pipMap: Record<string, string> = {
541
+ mysql: "mysql-connector-python",
542
+ postgres: "psycopg2-binary",
543
+ sqlserver: "pymssql",
544
+ };
545
+ const pipPkg = pipMap[rdbDef.provider];
546
+ if (!pipPkg) return;
547
+ const reqPath = path.join(functionsPath, "requirements.txt");
548
+ if (fs.existsSync(reqPath)) {
549
+ const existing = fs.readFileSync(reqPath, "utf-8");
550
+ if (!existing.includes(pipPkg)) {
551
+ console.log(`\n📦 Adding ${pipPkg} to requirements.txt...`);
552
+ fs.appendFileSync(reqPath, `\n${pipPkg}\n`);
553
+ console.log(`✅ ${pipPkg} added`);
554
+ }
555
+ }
556
+ }
557
+
404
558
  /**
405
559
  * コネクタモデル用 BFF ルート生成(操作制限に対応)
406
560
  */
@@ -874,7 +1028,7 @@ module ${containerModuleName} 'containers/${modelKebab}-container.bicep' = {
874
1028
  /**
875
1029
  * Generate UI components (list, detail, form, create, edit pages)
876
1030
  */
877
- async function generateUIComponents(modelInfo: any, sharedPackageName: string): Promise<void> {
1031
+ async function generateUIComponents(modelInfo: any, sharedPackageName: string, authOptions?: UIAuthOptions): Promise<void> {
878
1032
  console.log("\n🎨 Generating UI components...");
879
1033
 
880
1034
  const modelKebab = toKebabCase(modelInfo.name);
@@ -896,11 +1050,11 @@ async function generateUIComponents(modelInfo: any, sharedPackageName: string):
896
1050
  });
897
1051
 
898
1052
  // Generate and write files
899
- const listPage = generateListPage(modelInfo, sharedPackageName);
900
- const detailPage = generateDetailPage(modelInfo, sharedPackageName);
1053
+ const listPage = generateListPage(modelInfo, sharedPackageName, authOptions);
1054
+ const detailPage = generateDetailPage(modelInfo, sharedPackageName, authOptions);
901
1055
  const formComponent = generateFormComponent(modelInfo, sharedPackageName);
902
- const newPage = generateNewPage(modelInfo);
903
- const editPage = generateEditPage(modelInfo, sharedPackageName);
1056
+ const newPage = generateNewPage(modelInfo, authOptions);
1057
+ const editPage = generateEditPage(modelInfo, sharedPackageName, authOptions);
904
1058
 
905
1059
  fs.writeFileSync(path.join(modelDir, "page.tsx"), listPage, "utf-8");
906
1060
  fs.writeFileSync(path.join(idDir, "page.tsx"), detailPage, "utf-8");
package/src/cli/index.ts CHANGED
@@ -11,6 +11,7 @@ import { Command } from "commander";
11
11
  import { initCommand, devCommand, devSeedsCommand, scaffoldCommand, createModelCommand } from "./commands";
12
12
  import { provisionCommand } from "./commands/provision";
13
13
  import { addConnectorCommand } from "./commands/add-connector";
14
+ import { addAuthCommand } from "./commands/add-auth";
14
15
 
15
16
  const program = new Command();
16
17
 
@@ -87,4 +88,14 @@ program
87
88
  });
88
89
  });
89
90
 
91
+ program
92
+ .command("add-auth")
93
+ .description("Add authentication and authorization to the project")
94
+ .option("--provider <provider>", "Auth provider: custom-jwt | swa | swa-custom | none", "custom-jwt")
95
+ .action((options) => {
96
+ addAuthCommand({
97
+ provider: options.provider,
98
+ });
99
+ });
100
+
90
101
  program.parse();
@@ -1,6 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { BackendLanguage, ConnectorDefinition, SwallowKitConfig } from "../types";
3
+ import { AuthConfig, BackendLanguage, ConnectorDefinition, SwallowKitConfig } from "../types";
4
4
  import { detectFromProject, getCommands } from "../utils/package-manager";
5
5
 
6
6
  const VALID_BACKEND_LANGUAGES: BackendLanguage[] = ["typescript", "csharp", "python"];
@@ -81,7 +81,10 @@ function mergeConfig(defaultConfig: SwallowKitConfig, userConfig: Partial<Swallo
81
81
  ...userConfig.api?.cors,
82
82
  },
83
83
  },
84
- ...(userConfig.connectors ? { connectors: userConfig.connectors } : {}),
84
+ connectors: userConfig.connectors ?? defaultConfig.connectors,
85
+ auth: userConfig.auth
86
+ ? { ...defaultConfig.auth, ...userConfig.auth }
87
+ : defaultConfig.auth,
85
88
  };
86
89
  }
87
90
 
@@ -177,6 +180,14 @@ export function getConnectorDefinition(connectorName: string, configPath?: strin
177
180
  return config.connectors?.[connectorName];
178
181
  }
179
182
 
183
+ /**
184
+ * 認証設定を取得
185
+ */
186
+ export function getAuthConfig(configPath?: string): AuthConfig | undefined {
187
+ const config = getFullConfig(configPath);
188
+ return config.auth;
189
+ }
190
+
180
191
  /**
181
192
  * 設定の検証
182
193
  */
@@ -26,6 +26,23 @@ export interface ConnectorMockServerOptions {
26
26
  mockCount?: number;
27
27
  /** ホスト名 */
28
28
  host?: string;
29
+ /** Auth config — auth functions use RDB connector, mocked the same way */
30
+ authConfig?: {
31
+ /** JWT secret for mock token generation/verification */
32
+ jwtSecret: string;
33
+ /** Token expiry (e.g., '24h') */
34
+ tokenExpiry?: string;
35
+ /** Custom JWT config from swallowkit.config.js */
36
+ customJwt?: {
37
+ /** RDB table name that holds user records (e.g., "users") */
38
+ userTable: string;
39
+ loginIdColumn: string;
40
+ passwordHashColumn: string;
41
+ rolesColumn: string;
42
+ };
43
+ /** Default auth policy: 'authenticated' = all models need auth, 'anonymous' = only models with authPolicy */
44
+ defaultPolicy?: "authenticated" | "anonymous";
45
+ };
29
46
  }
30
47
 
31
48
  type MockDocument = Record<string, unknown>;
@@ -128,6 +145,12 @@ export class ConnectorMockServer {
128
145
  private handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
129
146
  const { route, id } = this.parseRoute(req.url || "");
130
147
 
148
+ // Auth endpoints (when authConfig is set)
149
+ if (this.options.authConfig && route === "auth") {
150
+ this.handleAuthRequest(req, res, id);
151
+ return;
152
+ }
153
+
131
154
  // コネクタモデルのルートか判定
132
155
  if (route && this.routeMap.has(route)) {
133
156
  this.handleMockCrud(req, res, route, id);
@@ -165,6 +188,11 @@ export class ConnectorMockServer {
165
188
  const model = this.routeMap.get(route)!;
166
189
  const ops = model.connectorConfig?.operations || [];
167
190
 
191
+ const isWrite = method === "POST" || method === "PUT" || method === "DELETE";
192
+ const requiredRoles = this.resolveRequiredRoles(model, isWrite);
193
+ const authResult = this.checkAuth(req, res, model, requiredRoles);
194
+ if (authResult === "error") return; // 401/403 already sent
195
+
168
196
  switch (method) {
169
197
  case "GET":
170
198
  req.resume();
@@ -258,8 +286,228 @@ export class ConnectorMockServer {
258
286
  req.pipe(proxyReq, { end: true });
259
287
  }
260
288
 
289
+ // ─── Auth Routes (RDB user queries + JWT generation) ───
290
+
291
+ private handleAuthRequest(
292
+ req: http.IncomingMessage,
293
+ res: http.ServerResponse,
294
+ endpoint: string | null
295
+ ) {
296
+ const method = (req.method || "GET").toUpperCase();
297
+
298
+ // Handle CORS preflight
299
+ if (method === "OPTIONS") {
300
+ req.resume();
301
+ res.writeHead(200, {
302
+ "Access-Control-Allow-Origin": "*",
303
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
304
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
305
+ });
306
+ res.end();
307
+ return;
308
+ }
309
+
310
+ if (endpoint === "login" && method === "POST") {
311
+ this.handleLogin(req, res);
312
+ } else if (endpoint === "me" && method === "GET") {
313
+ this.handleMe(req, res);
314
+ } else if (endpoint === "logout" && method === "POST") {
315
+ req.resume();
316
+ this.sendJson(res, 200, { message: "Logged out" });
317
+ } else {
318
+ req.resume();
319
+ this.sendJson(res, 404, { error: "Auth endpoint not found" });
320
+ }
321
+ }
322
+
323
+ private handleLogin(req: http.IncomingMessage, res: http.ServerResponse) {
324
+ this.readBody(req, (body) => {
325
+ const loginId = body.loginId as string;
326
+ const password = body.password as string;
327
+
328
+ if (!loginId || !password) {
329
+ return this.sendJson(res, 400, { error: "loginId and password are required" });
330
+ }
331
+
332
+ const users = this.resolveUserStore();
333
+ if (!users) {
334
+ return this.sendJson(res, 500, {
335
+ error: "No user model found — ensure a connector model with the configured userTable exists",
336
+ });
337
+ }
338
+
339
+ const loginField = this.options.authConfig?.customJwt?.loginIdColumn || "loginId";
340
+ const passwordField = this.options.authConfig?.customJwt?.passwordHashColumn || "password";
341
+ const rolesField = this.options.authConfig?.customJwt?.rolesColumn || "roles";
342
+
343
+ const user = users.find(
344
+ (u) => (u[loginField] || u.loginId) === loginId
345
+ );
346
+
347
+ if (!user) {
348
+ return this.sendJson(res, 401, { error: "Invalid credentials" });
349
+ }
350
+
351
+ // Plaintext password comparison (mock mode only)
352
+ const storedPassword = (user[passwordField] || user.password) as string;
353
+ if (storedPassword !== password) {
354
+ return this.sendJson(res, 401, { error: "Invalid credentials" });
355
+ }
356
+
357
+ // Parse roles
358
+ let roles: string[] = [];
359
+ const rolesValue = user[rolesField] || user.roles;
360
+ if (Array.isArray(rolesValue)) {
361
+ roles = rolesValue as string[];
362
+ } else if (typeof rolesValue === "string") {
363
+ try {
364
+ roles = JSON.parse(rolesValue);
365
+ } catch {
366
+ roles = (rolesValue as string).split(",").map((r: string) => r.trim());
367
+ }
368
+ }
369
+
370
+ const authUser = {
371
+ id: String(user.id),
372
+ loginId: String(user[loginField] || user.loginId),
373
+ name: String(user.name || user[loginField] || user.loginId),
374
+ email: String(user.email || ""),
375
+ roles,
376
+ };
377
+
378
+ // Generate JWT
379
+ const jwt = require("jsonwebtoken");
380
+ const secret = this.options.authConfig!.jwtSecret;
381
+ const expiry = this.options.authConfig!.tokenExpiry || "24h";
382
+
383
+ const token = jwt.sign(
384
+ {
385
+ sub: authUser.id,
386
+ loginId: authUser.loginId,
387
+ name: authUser.name,
388
+ email: authUser.email,
389
+ roles: authUser.roles,
390
+ },
391
+ secret,
392
+ { expiresIn: expiry }
393
+ );
394
+
395
+ const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
396
+
397
+ this.sendJson(res, 200, { user: authUser, token, expiresAt });
398
+ });
399
+ }
400
+
401
+ private handleMe(req: http.IncomingMessage, res: http.ServerResponse) {
402
+ req.resume();
403
+
404
+ const authHeader = req.headers["authorization"];
405
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
406
+ return this.sendJson(res, 401, { error: "Missing or invalid Authorization header" });
407
+ }
408
+
409
+ const token = authHeader.slice(7);
410
+ try {
411
+ const jwt = require("jsonwebtoken");
412
+ const secret = this.options.authConfig!.jwtSecret;
413
+ const payload = jwt.verify(token, secret) as Record<string, unknown>;
414
+ this.sendJson(res, 200, {
415
+ sub: payload.sub,
416
+ loginId: payload.loginId,
417
+ name: payload.name,
418
+ email: payload.email,
419
+ roles: payload.roles,
420
+ });
421
+ } catch {
422
+ this.sendJson(res, 401, { error: "Invalid or expired token" });
423
+ }
424
+ }
425
+
261
426
  // ─── Utilities ────────────────────────────────────────────
262
427
 
428
+ /**
429
+ * authConfig.customJwt.userTable に対応するモデルのストアを返す。
430
+ * ユーザーテーブルが見つからない場合は null を返す。
431
+ */
432
+ private resolveUserStore(): MockDocument[] | null {
433
+ const userTable = this.options.authConfig?.customJwt?.userTable;
434
+ if (!userTable) return null;
435
+
436
+ for (const model of this.options.connectorModels) {
437
+ const cfg = model.connectorConfig;
438
+ if (cfg && "table" in cfg && cfg.table === userTable) {
439
+ return this.stores.get(toCamelCase(model.name)) || null;
440
+ }
441
+ }
442
+ return null;
443
+ }
444
+
445
+ /**
446
+ * JWT を検証し、ペイロード(roles 含む)を返す。
447
+ * 認証不要な場合は null を返す。401/403 の場合はレスポンスを送信して 'error' を返す。
448
+ */
449
+ private checkAuth(
450
+ req: http.IncomingMessage,
451
+ res: http.ServerResponse,
452
+ model: ModelInfo,
453
+ requiredRoles: string[] | undefined
454
+ ): Record<string, unknown> | null | "error" {
455
+ const authCfg = this.options.authConfig;
456
+ if (!authCfg) return null; // auth 未設定 → 全スルー
457
+
458
+ const policy = model.authPolicy;
459
+ const defaultPolicy = authCfg.defaultPolicy || "anonymous";
460
+
461
+ // モデルに authPolicy がなく defaultPolicy が anonymous → 認証不要
462
+ if (!policy && defaultPolicy === "anonymous") return null;
463
+
464
+ // JWT 検証
465
+ const authHeader = req.headers["authorization"];
466
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
467
+ this.sendJson(res, 401, { error: "Missing or invalid Authorization header" });
468
+ return "error";
469
+ }
470
+
471
+ const token = authHeader.slice(7);
472
+ try {
473
+ const jwt = require("jsonwebtoken");
474
+ const payload = jwt.verify(token, authCfg.jwtSecret) as Record<string, unknown>;
475
+
476
+ // ロールチェック
477
+ if (requiredRoles && requiredRoles.length > 0) {
478
+ const userRoles = Array.isArray(payload.roles) ? (payload.roles as string[]) : [];
479
+ const hasRole = requiredRoles.some((r) => userRoles.includes(r));
480
+ if (!hasRole) {
481
+ this.sendJson(res, 403, {
482
+ error: `Requires one of roles: ${requiredRoles.join(", ")}`,
483
+ });
484
+ return "error";
485
+ }
486
+ }
487
+
488
+ return payload;
489
+ } catch {
490
+ this.sendJson(res, 401, { error: "Invalid or expired token" });
491
+ return "error";
492
+ }
493
+ }
494
+
495
+ /**
496
+ * モデルの authPolicy から read/write に必要なロールを解決
497
+ */
498
+ private resolveRequiredRoles(
499
+ model: ModelInfo,
500
+ isWrite: boolean
501
+ ): string[] | undefined {
502
+ const policy = model.authPolicy;
503
+ if (!policy) return undefined; // defaultPolicy: authenticated → 認証のみ、ロールチェック不要
504
+
505
+ if (isWrite) {
506
+ return policy.write || policy.roles;
507
+ }
508
+ return policy.read || policy.roles;
509
+ }
510
+
263
511
  private readBody(req: http.IncomingMessage, callback: (body: MockDocument) => void) {
264
512
  let data = "";
265
513
  req.on("data", (chunk) => (data += chunk));