swallowkit 1.0.0-beta.46 → 1.0.0-beta.48

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 (84) hide show
  1. package/dist/cli/commands/add-auth.d.ts +3 -0
  2. package/dist/cli/commands/add-auth.d.ts.map +1 -1
  3. package/dist/cli/commands/add-auth.js +350 -17
  4. package/dist/cli/commands/add-auth.js.map +1 -1
  5. package/dist/cli/commands/dev.d.ts +4 -0
  6. package/dist/cli/commands/dev.d.ts.map +1 -1
  7. package/dist/cli/commands/dev.js +104 -13
  8. package/dist/cli/commands/dev.js.map +1 -1
  9. package/dist/cli/commands/init.d.ts +2 -0
  10. package/dist/cli/commands/init.d.ts.map +1 -1
  11. package/dist/cli/commands/init.js +56 -30
  12. package/dist/cli/commands/init.js.map +1 -1
  13. package/dist/cli/commands/scaffold.d.ts +9 -0
  14. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  15. package/dist/cli/commands/scaffold.js +84 -8
  16. package/dist/cli/commands/scaffold.js.map +1 -1
  17. package/dist/cli/index.d.ts.map +1 -1
  18. package/dist/cli/index.js +5 -1
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/core/config.d.ts +7 -2
  21. package/dist/core/config.d.ts.map +1 -1
  22. package/dist/core/config.js +81 -1
  23. package/dist/core/config.js.map +1 -1
  24. package/dist/core/mock/connector-mock-server.js +2 -2
  25. package/dist/core/mock/connector-mock-server.js.map +1 -1
  26. package/dist/core/project/manifest.js +2 -2
  27. package/dist/core/project/manifest.js.map +1 -1
  28. package/dist/core/project/validation.d.ts.map +1 -1
  29. package/dist/core/project/validation.js +17 -0
  30. package/dist/core/project/validation.js.map +1 -1
  31. package/dist/core/scaffold/auth-generator.d.ts +28 -3
  32. package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
  33. package/dist/core/scaffold/auth-generator.js +493 -23
  34. package/dist/core/scaffold/auth-generator.js.map +1 -1
  35. package/dist/core/scaffold/connector-functions-generator.d.ts +3 -3
  36. package/dist/core/scaffold/connector-functions-generator.d.ts.map +1 -1
  37. package/dist/core/scaffold/connector-functions-generator.js +23 -21
  38. package/dist/core/scaffold/connector-functions-generator.js.map +1 -1
  39. package/dist/core/scaffold/functions-generator.d.ts +2 -2
  40. package/dist/core/scaffold/functions-generator.d.ts.map +1 -1
  41. package/dist/core/scaffold/functions-generator.js +20 -18
  42. package/dist/core/scaffold/functions-generator.js.map +1 -1
  43. package/dist/core/scaffold/model-parser.d.ts.map +1 -1
  44. package/dist/core/scaffold/model-parser.js +8 -3
  45. package/dist/core/scaffold/model-parser.js.map +1 -1
  46. package/dist/core/scaffold/nextjs-generator.d.ts.map +1 -1
  47. package/dist/core/scaffold/nextjs-generator.js +13 -1
  48. package/dist/core/scaffold/nextjs-generator.js.map +1 -1
  49. package/dist/core/scaffold/ui-generator.d.ts +3 -0
  50. package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
  51. package/dist/core/scaffold/ui-generator.js +12 -8
  52. package/dist/core/scaffold/ui-generator.js.map +1 -1
  53. package/dist/types/index.d.ts +35 -5
  54. package/dist/types/index.d.ts.map +1 -1
  55. package/dist/utils/package-manager.d.ts.map +1 -1
  56. package/dist/utils/package-manager.js +1 -0
  57. package/dist/utils/package-manager.js.map +1 -1
  58. package/package.json +1 -1
  59. package/src/__tests__/__snapshots__/functions-generator.test.ts.snap +20 -20
  60. package/src/__tests__/__snapshots__/nextjs-generator.test.ts.snap +13 -1
  61. package/src/__tests__/auth.test.ts +107 -0
  62. package/src/__tests__/config.test.ts +22 -1
  63. package/src/__tests__/dev.test.ts +27 -0
  64. package/src/__tests__/init.test.ts +19 -0
  65. package/src/__tests__/mcp.test.ts +2 -2
  66. package/src/__tests__/multi-auth.test.ts +133 -0
  67. package/src/__tests__/nextjs-generator.test.ts +8 -0
  68. package/src/cli/commands/add-auth.ts +351 -18
  69. package/src/cli/commands/dev.ts +116 -16
  70. package/src/cli/commands/init.ts +56 -30
  71. package/src/cli/commands/scaffold.ts +86 -10
  72. package/src/cli/index.ts +5 -1
  73. package/src/core/config.ts +69 -2
  74. package/src/core/mock/connector-mock-server.ts +2 -2
  75. package/src/core/project/manifest.ts +3 -3
  76. package/src/core/project/validation.ts +16 -0
  77. package/src/core/scaffold/auth-generator.ts +500 -24
  78. package/src/core/scaffold/connector-functions-generator.ts +26 -21
  79. package/src/core/scaffold/functions-generator.ts +21 -19
  80. package/src/core/scaffold/model-parser.ts +9 -3
  81. package/src/core/scaffold/nextjs-generator.ts +13 -1
  82. package/src/core/scaffold/ui-generator.ts +15 -8
  83. package/src/types/index.ts +36 -5
  84. package/src/utils/package-manager.ts +1 -0
@@ -130,7 +130,7 @@ public sealed class TodoCrudFunctions
130
130
 
131
131
  [Function("todoGetAll")]
132
132
  public async Task<HttpResponseData> GetAll(
133
- [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "todo")] HttpRequestData request)
133
+ [HttpTrigger(AuthorizationLevel.Function, "get", Route = "todo")] HttpRequestData request)
134
134
  {
135
135
  try
136
136
  {
@@ -171,7 +171,7 @@ public sealed class TodoCrudFunctions
171
171
 
172
172
  [Function("todoGetById")]
173
173
  public async Task<HttpResponseData> GetById(
174
- [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "todo/{id}")] HttpRequestData request,
174
+ [HttpTrigger(AuthorizationLevel.Function, "get", Route = "todo/{id}")] HttpRequestData request,
175
175
  string id)
176
176
  {
177
177
  try
@@ -194,7 +194,7 @@ public sealed class TodoCrudFunctions
194
194
 
195
195
  [Function("todoCreate")]
196
196
  public async Task<HttpResponseData> Create(
197
- [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "todo")] HttpRequestData request)
197
+ [HttpTrigger(AuthorizationLevel.Function, "post", Route = "todo")] HttpRequestData request)
198
198
  {
199
199
  try
200
200
  {
@@ -229,7 +229,7 @@ public sealed class TodoCrudFunctions
229
229
 
230
230
  [Function("todoUpdate")]
231
231
  public async Task<HttpResponseData> Update(
232
- [HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "todo/{id}")] HttpRequestData request,
232
+ [HttpTrigger(AuthorizationLevel.Function, "put", Route = "todo/{id}")] HttpRequestData request,
233
233
  string id)
234
234
  {
235
235
  try
@@ -274,7 +274,7 @@ public sealed class TodoCrudFunctions
274
274
 
275
275
  [Function("todoDelete")]
276
276
  public async Task<HttpResponseData> Delete(
277
- [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "todo/{id}")] HttpRequestData request,
277
+ [HttpTrigger(AuthorizationLevel.Function, "delete", Route = "todo/{id}")] HttpRequestData request,
278
278
  string id)
279
279
  {
280
280
  try
@@ -466,7 +466,7 @@ const containerName = 'Todos';
466
466
  app.http('todo-get-all', {
467
467
  methods: ['GET'],
468
468
  route: 'todo',
469
- authLevel: 'anonymous',
469
+ authLevel: 'function',
470
470
  extraInputs: [
471
471
  {
472
472
  type: 'cosmosDB',
@@ -500,7 +500,7 @@ app.http('todo-get-all', {
500
500
  app.http('todo-get-by-id', {
501
501
  methods: ['GET'],
502
502
  route: 'todo/{id}',
503
- authLevel: 'anonymous',
503
+ authLevel: 'function',
504
504
  handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
505
505
  try {
506
506
  const id = request.params.id;
@@ -542,7 +542,7 @@ app.http('todo-get-by-id', {
542
542
  app.http('todo-create', {
543
543
  methods: ['POST'],
544
544
  route: 'todo',
545
- authLevel: 'anonymous',
545
+ authLevel: 'function',
546
546
  extraOutputs: [
547
547
  {
548
548
  type: 'cosmosDB',
@@ -586,7 +586,7 @@ app.http('todo-create', {
586
586
  app.http('todo-update', {
587
587
  methods: ['PUT'],
588
588
  route: 'todo/{id}',
589
- authLevel: 'anonymous',
589
+ authLevel: 'function',
590
590
  handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
591
591
  try {
592
592
  const id = request.params.id;
@@ -647,7 +647,7 @@ app.http('todo-update', {
647
647
  app.http('todo-delete', {
648
648
  methods: ['DELETE'],
649
649
  route: 'todo/{id}',
650
- authLevel: 'anonymous',
650
+ authLevel: 'function',
651
651
  handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
652
652
  try {
653
653
  const id = request.params.id;
@@ -706,7 +706,7 @@ const containerName = 'Todos';
706
706
  app.http('todo-get-all', {
707
707
  methods: ['GET'],
708
708
  route: 'todo',
709
- authLevel: 'anonymous',
709
+ authLevel: 'function',
710
710
  extraInputs: [
711
711
  {
712
712
  type: 'cosmosDB',
@@ -740,7 +740,7 @@ app.http('todo-get-all', {
740
740
  app.http('todo-get-by-id', {
741
741
  methods: ['GET'],
742
742
  route: 'todo/{id}',
743
- authLevel: 'anonymous',
743
+ authLevel: 'function',
744
744
  extraInputs: [
745
745
  {
746
746
  type: 'cosmosDB',
@@ -773,7 +773,7 @@ app.http('todo-get-by-id', {
773
773
  app.http('todo-create', {
774
774
  methods: ['POST'],
775
775
  route: 'todo',
776
- authLevel: 'anonymous',
776
+ authLevel: 'function',
777
777
  extraOutputs: [
778
778
  {
779
779
  type: 'cosmosDB',
@@ -817,7 +817,7 @@ app.http('todo-create', {
817
817
  app.http('todo-update', {
818
818
  methods: ['PUT'],
819
819
  route: 'todo/{id}',
820
- authLevel: 'anonymous',
820
+ authLevel: 'function',
821
821
  extraInputs: [
822
822
  {
823
823
  type: 'cosmosDB',
@@ -880,7 +880,7 @@ app.http('todo-update', {
880
880
  app.http('todo-delete', {
881
881
  methods: ['DELETE'],
882
882
  route: 'todo/{id}',
883
- authLevel: 'anonymous',
883
+ authLevel: 'function',
884
884
  handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
885
885
  try {
886
886
  const id = request.params.id;
@@ -928,7 +928,7 @@ const containerName = 'Issues';
928
928
  app.http('issue-get-all', {
929
929
  methods: ['GET'],
930
930
  route: 'issue',
931
- authLevel: 'anonymous',
931
+ authLevel: 'function',
932
932
  extraInputs: [
933
933
  {
934
934
  type: 'cosmosDB',
@@ -962,7 +962,7 @@ app.http('issue-get-all', {
962
962
  app.http('issue-get-by-id', {
963
963
  methods: ['GET'],
964
964
  route: 'issue/{id}',
965
- authLevel: 'anonymous',
965
+ authLevel: 'function',
966
966
  extraInputs: [
967
967
  {
968
968
  type: 'cosmosDB',
@@ -995,7 +995,7 @@ app.http('issue-get-by-id', {
995
995
  app.http('issue-create', {
996
996
  methods: ['POST'],
997
997
  route: 'issue',
998
- authLevel: 'anonymous',
998
+ authLevel: 'function',
999
999
  extraOutputs: [
1000
1000
  {
1001
1001
  type: 'cosmosDB',
@@ -1039,7 +1039,7 @@ app.http('issue-create', {
1039
1039
  app.http('issue-update', {
1040
1040
  methods: ['PUT'],
1041
1041
  route: 'issue/{id}',
1042
- authLevel: 'anonymous',
1042
+ authLevel: 'function',
1043
1043
  extraInputs: [
1044
1044
  {
1045
1045
  type: 'cosmosDB',
@@ -1102,7 +1102,7 @@ app.http('issue-update', {
1102
1102
  app.http('issue-delete', {
1103
1103
  methods: ['DELETE'],
1104
1104
  route: 'issue/{id}',
1105
- authLevel: 'anonymous',
1105
+ authLevel: 'function',
1106
1106
  handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
1107
1107
  try {
1108
1108
  const id = request.params.id;
@@ -23,6 +23,17 @@ function getFunctionsBaseUrl(): string {
23
23
  return process.env.BACKEND_FUNCTIONS_BASE_URL || process.env.FUNCTIONS_BASE_URL || 'http://localhost:7071';
24
24
  }
25
25
 
26
+ function isLocalFunctionsUrl(url: string): boolean {
27
+ return url.startsWith('http://localhost:') || url.startsWith('http://127.0.0.1:');
28
+ }
29
+
30
+ function getFunctionsKeyHeaders(functionsBaseUrl: string): Record<string, string> {
31
+ const key = process.env.BACKEND_FUNCTIONS_KEY;
32
+ if (key) return { 'x-functions-key': key };
33
+ if (isLocalFunctionsUrl(functionsBaseUrl)) return {};
34
+ throw new Error('BACKEND_FUNCTIONS_KEY is required for non-local Functions calls');
35
+ }
36
+
26
37
  interface CallFunctionConfig<TInput = any, TOutput = any> {
27
38
  /** HTTP メソッド */
28
39
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
@@ -61,11 +72,12 @@ export async function callFunction<TInput = any, TOutput = any>(
61
72
  // Azure Functions を呼び出し
62
73
  const functionsBaseUrl = getFunctionsBaseUrl();
63
74
  const url = functionsBaseUrl + path;
75
+ const keyHeaders = getFunctionsKeyHeaders(functionsBaseUrl);
64
76
  console.log(\`[BFF] \${method} \${url}\`);
65
77
 
66
78
  const response = await fetch(url, {
67
79
  method,
68
- headers: { 'Content-Type': 'application/json' },
80
+ headers: { 'Content-Type': 'application/json', ...keyHeaders },
69
81
  body: validatedBody !== undefined ? JSON.stringify(validatedBody) : undefined,
70
82
  });
71
83
 
@@ -13,6 +13,17 @@ import {
13
13
  generateLoginPage,
14
14
  generateAuthContext,
15
15
  generateBFFCallFunctionWithAuth,
16
+ generateBFFCallFunctionWithSwaAuth,
17
+ generateSwaAuthHelperTS,
18
+ generateSwaAuthHelperCSharp,
19
+ generateSwaAuthHelperPython,
20
+ generateExternalTokenAdapter,
21
+ generateAuthenticatedFetch,
22
+ generateExternalTokenAuthContext,
23
+ generateExternalTokenVerifierTS,
24
+ generateExternalTokenHelperTS,
25
+ generateExternalTokenVerifierCSharp,
26
+ generateExternalTokenVerifierPython,
16
27
  generateAuthImportTS,
17
28
  generateAuthGuardTS,
18
29
  generateAuthGuardCSharp,
@@ -22,6 +33,7 @@ import { generateCompactAzureFunctionsCRUD } from "../core/scaffold/functions-ge
22
33
  import { parseAuthPolicy } from "../core/scaffold/model-parser";
23
34
  import { createBasicModelInfo } from "./fixtures";
24
35
  import { ModelAuthPolicy, CustomJwtConfig } from "../types";
36
+ import { addAuthProviderToRootLayout, generateSwaAuthContext } from "../cli/commands/add-auth";
25
37
 
26
38
  const defaultJwtConfig: CustomJwtConfig = {
27
39
  userConnector: "mysql",
@@ -325,6 +337,97 @@ describe("generateBFFCallFunctionWithAuth", () => {
325
337
  const code = generateBFFCallFunctionWithAuth();
326
338
  expect(code).toContain("catch");
327
339
  });
340
+
341
+ it("also attaches the Functions host key", () => {
342
+ const code = generateBFFCallFunctionWithAuth();
343
+ expect(code).toContain("BACKEND_FUNCTIONS_KEY");
344
+ expect(code).toContain("'x-functions-key'");
345
+ });
346
+ });
347
+
348
+ describe("SWA built-in auth generators", () => {
349
+ it("wraps the root layout with AuthProvider exactly once", () => {
350
+ const layout = `import './globals.css';\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return <html><body>{children}</body></html>;\n}\n`;
351
+ const once = addAuthProviderToRootLayout(layout);
352
+ const twice = addAuthProviderToRootLayout(once);
353
+
354
+ expect(twice).toBe(once);
355
+ expect(once.match(/import \{ AuthProvider \}/g)).toHaveLength(1);
356
+ expect(once.match(/<AuthProvider>/g)).toHaveLength(1);
357
+ expect(once).toContain("<AuthProvider>{children}</AuthProvider>");
358
+ });
359
+
360
+ it("preserves a React directive before the generated import", () => {
361
+ const layout = `'use client';\nimport { useAuth } from '@/lib/auth/auth-context';\nexport default function RootLayout({ children }: { children: React.ReactNode }) { return <body>{children}</body>; }`;
362
+ const updated = addAuthProviderToRootLayout(layout);
363
+
364
+ expect(updated).toMatch(/^'use client';\nimport \{ AuthProvider \}/);
365
+ expect(updated.match(/AuthProvider \} from/g)).toHaveLength(1);
366
+ });
367
+
368
+ it("treats unavailable or invalid /.auth/me responses as anonymous", () => {
369
+ const code = generateSwaAuthContext();
370
+ expect(code).toContain("if (!response.ok)");
371
+ expect(code).toContain("typeof body !== 'object'");
372
+ expect(code).toContain(".catch(() => setUser(null))");
373
+ expect(code).toContain(".finally(() => setLoading(false))");
374
+ });
375
+
376
+ it("normalizes a valid SWA principal and its string roles", () => {
377
+ const code = generateSwaAuthContext();
378
+ expect(code).toContain("typeof value.userId !== 'string'");
379
+ expect(code).toContain("value.userRoles.filter");
380
+ expect(code).toContain("satisfies AuthUser");
381
+ });
382
+
383
+ it("forwards a normalized platform principal with /.auth/me fallback", () => {
384
+ const code = generateBFFCallFunctionWithSwaAuth();
385
+ expect(code).toContain("x-ms-client-principal");
386
+ expect(code).toContain("/.auth/me");
387
+ expect(code).toContain("BACKEND_FUNCTIONS_KEY");
388
+ expect(code).not.toContain("fetchHeaders['Authorization']");
389
+ });
390
+
391
+ it.each([
392
+ ["typescript", generateSwaAuthHelperTS()],
393
+ ["csharp", generateSwaAuthHelperCSharp()],
394
+ ["python", generateSwaAuthHelperPython()],
395
+ ])("generates %s principal validation and role checks", (_language, code) => {
396
+ expect(code).toContain("x-ms-client-principal");
397
+ expect(code).toContain("authenticated");
398
+ expect(code.toLowerCase()).toContain("role");
399
+ expect(code).not.toContain("JWT_SECRET");
400
+ });
401
+ });
402
+
403
+ describe("external token auth generators", () => {
404
+ it("generates a fail-closed frontend adapter and authenticated fetch", () => {
405
+ expect(generateExternalTokenAdapter()).toContain("return null");
406
+ const fetchCode = generateAuthenticatedFetch();
407
+ expect(fetchCode).toContain("Authorization");
408
+ expect(fetchCode).toContain("Bearer");
409
+ expect(fetchCode).toContain("status: 401");
410
+ });
411
+
412
+ it("handles non-JSON auth/me responses without parsing them", () => {
413
+ const code = generateExternalTokenAuthContext();
414
+ expect(code).toContain("content-type");
415
+ expect(code).toContain(".catch(() => setUser(null))");
416
+ });
417
+
418
+ it.each([
419
+ ["typescript", generateExternalTokenVerifierTS()],
420
+ ["csharp", generateExternalTokenVerifierCSharp()],
421
+ ["python", generateExternalTokenVerifierPython()],
422
+ ])("generates a fail-closed %s verifier", (_language, code) => {
423
+ expect(code).toContain("External token verifier is not configured");
424
+ expect(code).not.toContain("JWT_SECRET");
425
+ });
426
+
427
+ it("awaits external token verification in TypeScript guards", () => {
428
+ expect(generateAuthGuardTS({}, "read", "external-token")).toContain("await requireAuth(request)");
429
+ expect(generateExternalTokenHelperTS()).toContain("await verifyExternalToken");
430
+ });
328
431
  });
329
432
 
330
433
  // ============================================================
@@ -340,6 +443,10 @@ describe("generateAuthImportTS", () => {
340
443
  });
341
444
 
342
445
  describe("generateAuthGuardTS", () => {
446
+ it("generates a named read/write policy guard", () => {
447
+ expect(generateAuthGuardTS({ read: "adminOnly", write: "lineUserOnly" }, "read")).toContain("requireAuth(request, \"adminOnly\")");
448
+ expect(generateAuthGuardTS({ policy: "adminOnly" }, "write")).toContain("requireAuth(request, \"adminOnly\")");
449
+ });
343
450
  it("generates authentication-only guard for empty policy", () => {
344
451
  const policy: ModelAuthPolicy = {};
345
452
  const readGuard = generateAuthGuardTS(policy, "read");
@@ -1,7 +1,28 @@
1
- import { validateConfig, loadConfigFromEnv } from "../core/config";
1
+ import { validateConfig, loadConfigFromEnv, normalizeAuthConfig } from "../core/config";
2
2
  import { SwallowKitConfig } from "../types";
3
3
 
4
4
  describe("validateConfig", () => {
5
+ it("normalizes a legacy provider as the default scheme", () => {
6
+ const auth = normalizeAuthConfig({ provider: "external-token", authorization: { defaultPolicy: "authenticated" } });
7
+ expect(auth?.schemes.default.provider).toBe("external-token");
8
+ expect(auth?.authorization.defaultPolicy).toBe("authenticated");
9
+ });
10
+
11
+ it("accepts SWA and external token named schemes", () => {
12
+ const result = validateConfig({ auth: { schemes: { admin: { provider: "swa" }, lineUser: { provider: "external-token" } }, authorization: { defaultPolicy: "anonymous", policies: { adminOnly: { schemes: ["admin"] }, lineUserOnly: { schemes: ["lineUser"] } } } } });
13
+ expect(result).toEqual({ valid: true, errors: [] });
14
+ });
15
+
16
+ it("rejects undefined scheme references and ambiguous bearer schemes", () => {
17
+ const missing = validateConfig({ auth: { schemes: { admin: { provider: "swa" } }, authorization: { defaultPolicy: "anonymous", policies: { bad: { schemes: ["missing"] } } } } });
18
+ expect(missing.errors.join("\n")).toContain("auth.authorization.policies.bad.schemes");
19
+ const ambiguous = validateConfig({ auth: { schemes: { a: { provider: "external-token" }, b: { provider: "external-token" } }, authorization: { defaultPolicy: "anonymous", policies: { bad: { schemes: ["a", "b"] } } } } });
20
+ expect(ambiguous.errors.join("\n")).toContain("deterministic");
21
+ });
22
+
23
+ it("normalizes public to anonymous", () => {
24
+ expect(normalizeAuthConfig({ provider: "none", authorization: { defaultPolicy: "public" } })?.authorization.defaultPolicy).toBe("anonymous");
25
+ });
5
26
  it("returns valid for a complete config", () => {
6
27
  const config: SwallowKitConfig = {
7
28
  database: { connectionString: "AccountEndpoint=https://..." },
@@ -6,6 +6,7 @@ import {
6
6
  buildFunctionsStartArgs,
7
7
  buildFunctionsCoreToolsCommand,
8
8
  buildNextDevArgs,
9
+ buildSwaStartArgs,
9
10
  buildDevCommand,
10
11
  buildPythonFunctionsEnv,
11
12
  compareVersionNumbers,
@@ -13,6 +14,7 @@ import {
13
14
  getFunctionsReadinessTimeoutMs,
14
15
  getCSharpFunctionsBuildArtifactPaths,
15
16
  getPythonVirtualEnvPaths,
17
+ getSwaCliInstallCommand,
16
18
  parseCoreToolsVersion,
17
19
  waitForHttpServerReady,
18
20
  } from "../cli/commands/dev";
@@ -70,6 +72,23 @@ describe("dev command helpers", () => {
70
72
  expect(buildFunctionsBaseUrl("127.0.0.1", "7072")).toBe("http://127.0.0.1:7072");
71
73
  });
72
74
 
75
+ it("builds SWA CLI arguments without bypassing the Next.js BFF", () => {
76
+ expect(buildSwaStartArgs(undefined, "3000", "4280")).toEqual([
77
+ "start",
78
+ "http://localhost:3000",
79
+ "--swa-config-location",
80
+ ".",
81
+ "--port",
82
+ "4280",
83
+ ]);
84
+ expect(buildSwaStartArgs(undefined, "3000", "4280")).not.toContain("--api-devserver-url");
85
+ });
86
+
87
+ it("provides a project-local SWA CLI install command", () => {
88
+ expect(getSwaCliInstallCommand("pnpm")).toBe("pnpm add -Dw @azure/static-web-apps-cli");
89
+ expect(getSwaCliInstallCommand("npm")).toBe("npm install -D @azure/static-web-apps-cli");
90
+ });
91
+
73
92
  it("gives C# more startup time before reporting readiness timeout", () => {
74
93
  expect(getFunctionsReadinessTimeoutMs("csharp")).toBeGreaterThan(getFunctionsReadinessTimeoutMs("typescript"));
75
94
  expect(getFunctionsReadinessTimeoutMs("csharp")).toBeGreaterThan(getFunctionsReadinessTimeoutMs("python"));
@@ -197,6 +216,8 @@ describe("dev CLI parser", () => {
197
216
  noFunctions: true,
198
217
  seedEnv: "local",
199
218
  mockConnectors: true,
219
+ swaPort: "4290",
220
+ noSwa: true,
200
221
  };
201
222
 
202
223
  const optionsAfterCommand = await parseDevOptions([
@@ -215,6 +236,9 @@ describe("dev CLI parser", () => {
215
236
  "--seed-env",
216
237
  "local",
217
238
  "--mock-connectors",
239
+ "--swa-port",
240
+ "4290",
241
+ "--no-swa",
218
242
  ]);
219
243
 
220
244
  const optionsBeforeCommand = await parseDevOptions([
@@ -226,6 +250,9 @@ describe("dev CLI parser", () => {
226
250
  "--seed-env",
227
251
  "local",
228
252
  "--mock-connectors",
253
+ "--swa-port",
254
+ "4290",
255
+ "--no-swa",
229
256
  "--no-functions",
230
257
  "--port",
231
258
  "3001",
@@ -17,6 +17,8 @@ import {
17
17
  injectSwallowKitNextConfig,
18
18
  parseIgnoredBuilds,
19
19
  buildCosmosDbFreeTierBicepSource,
20
+ buildFunctionsHostKeyBicepExpression,
21
+ buildStaticWebAppConfigBicepSource,
20
22
  withNpmLockfileSafeManifests,
21
23
  } from "../cli/commands/init";
22
24
 
@@ -100,6 +102,7 @@ module.exports = nextConfig;
100
102
  expect(dependencies).toEqual({
101
103
  "@azure/cosmos": "^4.0.0",
102
104
  applicationinsights: "^3.3.0",
105
+ zod: "^4.0.0",
103
106
  "@sample-app/shared": "workspace:*",
104
107
  });
105
108
  expect(dependencies).not.toHaveProperty("swallowkit");
@@ -212,6 +215,22 @@ describe("buildCosmosDbFreeTierBicepSource", () => {
212
215
  });
213
216
  });
214
217
 
218
+ describe("Functions host key infrastructure", () => {
219
+ it("reads the generated default host key without exposing a literal value", () => {
220
+ const expression = buildFunctionsHostKeyBicepExpression();
221
+ expect(expression).toContain("listKeys(");
222
+ expect(expression).toContain("functionKeys.default");
223
+ });
224
+
225
+ it("passes the key to SWA through a secure module parameter", () => {
226
+ const source = buildStaticWebAppConfigBicepSource();
227
+ expect(source).toContain("@secure()");
228
+ expect(source).toContain("param functionsHostKey string");
229
+ expect(source).toContain("BACKEND_FUNCTIONS_KEY: functionsHostKey");
230
+ expect(source).not.toContain("output functionsHostKey");
231
+ });
232
+ });
233
+
215
234
  describe("GitHub Actions workflow generation", () => {
216
235
  it("uses npm ci for the SWA workflow when the project was initialized with pnpm", () => {
217
236
  const workflow = buildGitHubSwaWorkflow("npm", "pnpm");
@@ -9,7 +9,7 @@ const itWhenBuiltEntrypointExists = fs.existsSync(builtMcpEntrypoint) ? it : it.
9
9
 
10
10
  describe("SwallowKit MCP tool definitions", () => {
11
11
  it("delegates inspect_project to the machine CLI", async () => {
12
- const runner = jest.fn(async (_args: Parameters<MachineCliRunner>[0]) => ({
12
+ const runner = jest.fn(async () => ({
13
13
  stdout: JSON.stringify({
14
14
  ok: true,
15
15
  command: "inspect-project",
@@ -48,7 +48,7 @@ describe("SwallowKit MCP tool definitions", () => {
48
48
  });
49
49
 
50
50
  it("delegates scaffold_model with explicit args", async () => {
51
- const runner = jest.fn(async (_args: Parameters<MachineCliRunner>[0]) => ({
51
+ const runner = jest.fn(async () => ({
52
52
  stdout: JSON.stringify({
53
53
  ok: true,
54
54
  command: "generate-scaffold",
@@ -0,0 +1,133 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { addAuthCommand } from "../cli/commands/add-auth";
5
+ import {
6
+ generateBFFCallFunctionWithMultipleAuth,
7
+ generateNamedAuthRouterCSharp,
8
+ generateNamedAuthRouterPython,
9
+ generateNamedAuthRouterTS,
10
+ } from "../core/scaffold/auth-generator";
11
+ import { normalizeAuthConfig } from "../core/config";
12
+ import { parseAuthPolicy } from "../core/scaffold/model-parser";
13
+ import { createBasicModelInfo } from "./fixtures";
14
+ import { generateCSharpAzureFunctionsCRUD, generateCompactAzureFunctionsCRUD, generatePythonAzureFunctionsCRUD } from "../core/scaffold/functions-generator";
15
+
16
+ const auth = normalizeAuthConfig({
17
+ schemes: {
18
+ admin: { provider: "swa" },
19
+ lineUser: { provider: "external-token" },
20
+ },
21
+ authorization: {
22
+ defaultPolicy: "anonymous",
23
+ policies: {
24
+ adminOnly: { schemes: ["admin"], roles: ["authenticated"] },
25
+ lineUserOnly: { schemes: ["lineUser"], roles: ["authenticated"] },
26
+ },
27
+ },
28
+ })!;
29
+
30
+ describe("named authentication routers", () => {
31
+ it("generates deterministic TypeScript policy routing and normalized principals", () => {
32
+ const code = generateNamedAuthRouterTS(auth);
33
+ expect(code).toContain("policy.schemes.filter");
34
+ expect(code).toContain("candidates.length !== 1");
35
+ expect(code).toContain("subject: value.userId");
36
+ expect(code).toContain("scheme + ':' + principal.subject");
37
+ expect(code).toContain("new AuthError(403");
38
+ expect(code).toContain("new AuthError(503");
39
+ expect(code).not.toContain("JWT_SECRET");
40
+ });
41
+
42
+ it("generates equivalent C# and Python named guards", () => {
43
+ const csharp = generateNamedAuthRouterCSharp(auth);
44
+ const python = generateNamedAuthRouterPython(auth);
45
+ expect(csharp).toContain("record AuthPrincipal(string Subject, string Scheme, string Issuer");
46
+ expect(csharp).toContain("candidates.Length != 1");
47
+ expect(csharp).toContain("HttpStatusCode.Forbidden");
48
+ expect(python).toContain('principal["scheme"] + ":" + principal["subject"]');
49
+ expect(python).toContain("len(candidates) != 1");
50
+ expect(python).toContain("AuthError(503");
51
+ });
52
+
53
+ it("generates valid branch structure for an SWA-only Python router", () => {
54
+ const swaOnly = normalizeAuthConfig({ schemes: { admin: { provider: "swa", swa: { allowedProviders: ["github"] } } }, authorization: { defaultPolicy: "anonymous", policies: { adminOnly: { schemes: ["admin"] } } } })!;
55
+ const python = generateNamedAuthRouterPython(swaOnly);
56
+ expect(python).toContain(' if PROVIDERS[scheme] == "swa":');
57
+ expect(python).not.toContain(' elif PROVIDERS[scheme] == "swa":');
58
+ expect(python).toContain("ALLOWED_PROVIDERS");
59
+ expect(generateNamedAuthRouterCSharp(swaOnly)).toContain("AllowedProviders[scheme]");
60
+ });
61
+
62
+ it("scaffolds named read/write guards for all Functions languages", () => {
63
+ const policy = { read: "adminOnly", write: "lineUserOnly" };
64
+ const model = createBasicModelInfo();
65
+ const ts = generateCompactAzureFunctionsCRUD(model, "@fixture/shared", policy);
66
+ const cs = generateCSharpAzureFunctionsCRUD(model, policy);
67
+ const py = generatePythonAzureFunctionsCRUD(model, policy).blueprint;
68
+ expect(ts).toContain("./auth/auth-router");
69
+ expect(ts).toContain('requireAuth(request, "adminOnly")');
70
+ expect(ts).toContain('requireAuth(request, "lineUserOnly")');
71
+ expect(cs).toContain('AuthRouter.Authorize(request, "adminOnly")');
72
+ expect(cs).toContain('AuthRouter.Authorize(request, "lineUserOnly")');
73
+ expect(py).toContain("from auth.auth_router import");
74
+ expect(py).toContain('require_auth(req, "adminOnly")');
75
+ expect(py).toContain('require_auth(req, "lineUserOnly")');
76
+ });
77
+
78
+ it("forwards both credential sources and the Functions host key", () => {
79
+ const code = generateBFFCallFunctionWithMultipleAuth();
80
+ expect(code).toContain("fetchHeaders['Authorization']");
81
+ expect(code).toContain("fetchHeaders['x-ms-client-principal']");
82
+ expect(code).toContain("'x-functions-key'");
83
+ expect(code).not.toMatch(/console\.log\([^\n]*(authorization|principal|cookie)/i);
84
+ });
85
+
86
+ it("parses model-wide and operation-specific named policies", () => {
87
+ expect(parseAuthPolicy("export const authPolicy = { policy: 'adminOnly' };"))
88
+ .toEqual({ policy: "adminOnly" });
89
+ expect(parseAuthPolicy("export const authPolicy = { read: 'adminOnly', write: 'lineUserOnly' };"))
90
+ .toEqual({ read: "adminOnly", write: "lineUserOnly" });
91
+ });
92
+ });
93
+
94
+ describe("add-auth named scheme CLI", () => {
95
+ const originalCwd = process.cwd();
96
+ let root: string;
97
+
98
+ beforeEach(() => {
99
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-multi-auth-"));
100
+ fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
101
+ fs.writeFileSync(path.join(root, "swallowkit.config.json"), JSON.stringify({ backend: { language: "typescript" }, auth: { schemes: {}, authorization: { defaultPolicy: "anonymous", policies: {} } } }, null, 2));
102
+ process.chdir(root);
103
+ });
104
+
105
+ afterEach(() => {
106
+ process.chdir(originalCwd);
107
+ fs.rmSync(root, { recursive: true, force: true });
108
+ });
109
+
110
+ it("adds two schemes without overwriting the first verifier", async () => {
111
+ await addAuthCommand({ scheme: "admin", provider: "swa" });
112
+ await addAuthCommand({ scheme: "lineUser", provider: "external-token" });
113
+ const verifier = path.join(root, "functions", "src", "auth", "schemes", "line-user", "verifier.ts");
114
+ fs.writeFileSync(verifier, "// user verifier\n", "utf-8");
115
+ const config = JSON.parse(fs.readFileSync(path.join(root, "swallowkit.config.json"), "utf-8"));
116
+ expect(config.auth.schemes.admin.provider).toBe("swa");
117
+ expect(config.auth.schemes.lineUser.provider).toBe("external-token");
118
+ await expect(addAuthCommand({ scheme: "lineUser", provider: "external-token" })).rejects.toThrow("already exists");
119
+ expect(fs.readFileSync(verifier, "utf-8")).toBe("// user verifier\n");
120
+ expect(fs.readFileSync(path.join(root, "lib", "api", "call-function.ts"), "utf-8")).toContain("x-functions-key");
121
+ });
122
+
123
+ it("updates a JavaScript config that already has a trailing comma", async () => {
124
+ fs.rmSync(path.join(root, "swallowkit.config.json"));
125
+ fs.writeFileSync(path.join(root, "swallowkit.config.js"), "module.exports = {\n backend: { language: 'typescript' },\n api: { endpoint: '/api/test' },\n};\n");
126
+ await addAuthCommand({ scheme: "admin", provider: "swa" });
127
+ const content = fs.readFileSync(path.join(root, "swallowkit.config.js"), "utf-8");
128
+ expect(content).not.toContain(",,");
129
+ const configModule = { exports: {} as Record<string, unknown> };
130
+ new Function("module", content)(configModule);
131
+ expect((configModule.exports as { auth: { schemes: { admin: { provider: string } } } }).auth.schemes.admin.provider).toBe("swa");
132
+ });
133
+ });
@@ -21,6 +21,14 @@ describe("generateBFFCallFunction", () => {
21
21
  expect(code).toContain("export async function callFunction");
22
22
  });
23
23
 
24
+ it("uses a server-side Functions key and fails closed outside localhost", () => {
25
+ const code = generateBFFCallFunction();
26
+ expect(code).toContain("BACKEND_FUNCTIONS_KEY");
27
+ expect(code).toContain("'x-functions-key'");
28
+ expect(code).toContain("required for non-local Functions calls");
29
+ expect(code).not.toContain("?code=");
30
+ });
31
+
24
32
  it("includes z.ZodSchema typings", () => {
25
33
  const code = generateBFFCallFunction();
26
34
  expect(code).toContain("z.ZodSchema");