@horka/app-forge 0.1.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 (141) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +99 -0
  3. package/bin/cli.js +371 -0
  4. package/bin/cli.test.js +91 -0
  5. package/package.json +43 -0
  6. package/templates/core/CLAUDE.md +36 -0
  7. package/templates/core/claude/memory/ARCHITECTURE.md +20 -0
  8. package/templates/core/claude/memory/COMMANDS.md +13 -0
  9. package/templates/core/claude/memory/DECISIONS.md +5 -0
  10. package/templates/core/claude/memory/NEXT_STEPS.md +11 -0
  11. package/templates/core/claude/memory/PROJECT_STATE.md +24 -0
  12. package/templates/core/claude/skills/kickoff/SKILL.md +84 -0
  13. package/templates/core/claude/skills/product-owner/SKILL.md +58 -0
  14. package/templates/core/claude/skills/restore-context/SKILL.md +29 -0
  15. package/templates/core/claude/skills/save-context/SKILL.md +35 -0
  16. package/templates/core/docs-architecture/ANTI_PATTERNS.md +180 -0
  17. package/templates/core/docs-architecture/ARCHITECTURE_PRINCIPLES.md +134 -0
  18. package/templates/core/docs-architecture/DELIVERY.md +68 -0
  19. package/templates/core/docs-architecture/DOCS_PLACEMENT.md +151 -0
  20. package/templates/core/docs-architecture/MULTI_REPO_CONTRACT.md +158 -0
  21. package/templates/core/docs-architecture/SDK_CONTRACT.md +214 -0
  22. package/templates/core/docs-architecture/SECURITY_USER_URLS.md +152 -0
  23. package/templates/core/gitignore +15 -0
  24. package/templates/core/mcp.json +8 -0
  25. package/templates/packs/nuxt-web/CLAUDE.md +74 -0
  26. package/templates/packs/nuxt-web/app/app.vue +5 -0
  27. package/templates/packs/nuxt-web/app/assets/css/main.css +18 -0
  28. package/templates/packs/nuxt-web/app/assets/css/tokens.css +41 -0
  29. package/templates/packs/nuxt-web/app/designSystem/DSButton/components/DSButton.vue +70 -0
  30. package/templates/packs/nuxt-web/app/designSystem/DSButton/index.ts +4 -0
  31. package/templates/packs/nuxt-web/app/designSystem/DSButton/tests/DSButton.spec.ts +34 -0
  32. package/templates/packs/nuxt-web/app/designSystem/DSButton/types/dsButton.ts +5 -0
  33. package/templates/packs/nuxt-web/app/domain/.gitkeep +0 -0
  34. package/templates/packs/nuxt-web/app/features/.gitkeep +0 -0
  35. package/templates/packs/nuxt-web/app/pages/index.vue +36 -0
  36. package/templates/packs/nuxt-web/app/utils/.gitkeep +0 -0
  37. package/templates/packs/nuxt-web/claude/memory/COMMANDS.md +21 -0
  38. package/templates/packs/nuxt-web/docs-architecture/ARCHITECTURE.md +169 -0
  39. package/templates/packs/nuxt-web/docs-architecture/CONVENTIONS.md +140 -0
  40. package/templates/packs/nuxt-web/docs-architecture/I18N.md +102 -0
  41. package/templates/packs/nuxt-web/docs-architecture/OPS_WEB.md +176 -0
  42. package/templates/packs/nuxt-web/docs-architecture/SEO_AND_ROUTING.md +118 -0
  43. package/templates/packs/nuxt-web/gitignore +18 -0
  44. package/templates/packs/nuxt-web/nuxt.config.ts +49 -0
  45. package/templates/packs/nuxt-web/pack.json +11 -0
  46. package/templates/packs/nuxt-web/package.json +31 -0
  47. package/templates/packs/nuxt-web/playwright.config.ts +39 -0
  48. package/templates/packs/nuxt-web/server/api/health.get.ts +7 -0
  49. package/templates/packs/nuxt-web/tests/e2e/home.spec.ts +19 -0
  50. package/templates/packs/nuxt-web/tsconfig.json +4 -0
  51. package/templates/packs/nuxt-web/vitest.config.ts +23 -0
  52. package/templates/packs/swift-ios/CLAUDE.md +64 -0
  53. package/templates/packs/swift-ios/Packages/DataLayer/Package.swift +21 -0
  54. package/templates/packs/swift-ios/Packages/DataLayer/Sources/DataLayer/DataLayer.swift +11 -0
  55. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Package.swift +20 -0
  56. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Domain/SampleItem.swift +15 -0
  57. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Engine/SampleEngine.swift +14 -0
  58. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Repository/SampleItemRepository.swift +27 -0
  59. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Tests/{{PROJECT_NAME}}CoreTests/SampleEngineTests.swift +32 -0
  60. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Package.swift +17 -0
  61. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Color+DS.swift +18 -0
  62. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Components/DSCard.swift +22 -0
  63. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DS.swift +36 -0
  64. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DSFont.swift +26 -0
  65. package/templates/packs/swift-ios/claude/memory/COMMANDS.md +18 -0
  66. package/templates/packs/swift-ios/docs-architecture/ARCHITECTURE.md +246 -0
  67. package/templates/packs/swift-ios/docs-architecture/CLOUDKIT_GUIDE.md +224 -0
  68. package/templates/packs/swift-ios/docs-architecture/CONVENTIONS.md +246 -0
  69. package/templates/packs/swift-ios/docs-architecture/DESIGN_SYSTEM.md +272 -0
  70. package/templates/packs/swift-ios/docs-architecture/NAVIGATION.md +241 -0
  71. package/templates/packs/swift-ios/docs-architecture/TESTING.md +176 -0
  72. package/templates/packs/swift-ios/docs-architecture/WORKFLOW.md +165 -0
  73. package/templates/packs/swift-ios/github/workflows/ci.yml +48 -0
  74. package/templates/packs/swift-ios/gitignore +5 -0
  75. package/templates/packs/swift-ios/mcp.json +8 -0
  76. package/templates/packs/swift-ios/pack.json +11 -0
  77. package/templates/packs/swift-ios/project.yml +33 -0
  78. package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/App.swift +32 -0
  79. package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/AppNamespace.swift +4 -0
  80. package/templates/packs/swift-ios/{{PROJECT_NAME}}/Module/.gitkeep +0 -0
  81. package/templates/packs/swift-ios/{{PROJECT_NAME}}/Store/.gitkeep +0 -0
  82. package/templates/packs/swift-ios/{{PROJECT_NAME}}/Tools/.gitkeep +0 -0
  83. package/templates/packs/ts-sdk/CHANGELOG.md +9 -0
  84. package/templates/packs/ts-sdk/CLAUDE.md +72 -0
  85. package/templates/packs/ts-sdk/MIGRATION.md +28 -0
  86. package/templates/packs/ts-sdk/claude/memory/COMMANDS.md +21 -0
  87. package/templates/packs/ts-sdk/docs-architecture/ARCHITECTURE.md +132 -0
  88. package/templates/packs/ts-sdk/docs-architecture/CONVENTIONS_TS.md +152 -0
  89. package/templates/packs/ts-sdk/gitignore +6 -0
  90. package/templates/packs/ts-sdk/pack.json +11 -0
  91. package/templates/packs/ts-sdk/package.json +55 -0
  92. package/templates/packs/ts-sdk/scripts/verify-dist.mjs +67 -0
  93. package/templates/packs/ts-sdk/src/clients/AuthClient.ts +168 -0
  94. package/templates/packs/ts-sdk/src/core/HttpClient.ts +85 -0
  95. package/templates/packs/ts-sdk/src/core/Logger.ts +27 -0
  96. package/templates/packs/ts-sdk/src/core/SDKContext.ts +40 -0
  97. package/templates/packs/ts-sdk/src/core/withTimeout.ts +19 -0
  98. package/templates/packs/ts-sdk/src/errors/ApiError.ts +93 -0
  99. package/templates/packs/ts-sdk/src/index.ts +62 -0
  100. package/templates/packs/ts-sdk/src/types/index.ts +33 -0
  101. package/templates/packs/ts-sdk/tests/apiError.test.ts +58 -0
  102. package/templates/packs/ts-sdk/tests/httpClient.test.ts +60 -0
  103. package/templates/packs/ts-sdk/tests/singleFlight.test.ts +191 -0
  104. package/templates/packs/ts-sdk/tsconfig.json +15 -0
  105. package/templates/packs/ts-sdk/tsup.config.ts +22 -0
  106. package/templates/packs/ts-sdk/vitest.config.ts +8 -0
  107. package/templates/packs/vapor-api/CLAUDE.md +73 -0
  108. package/templates/packs/vapor-api/Dockerfile +80 -0
  109. package/templates/packs/vapor-api/Package.swift +68 -0
  110. package/templates/packs/vapor-api/Sources/App/App.swift +5 -0
  111. package/templates/packs/vapor-api/Sources/App/Configure/AppConfig.swift +108 -0
  112. package/templates/packs/vapor-api/Sources/App/Configure/configure.swift +74 -0
  113. package/templates/packs/vapor-api/Sources/App/Configure/entrypoint.swift +47 -0
  114. package/templates/packs/vapor-api/Sources/App/Configure/routes.swift +21 -0
  115. package/templates/packs/vapor-api/Sources/App/Error/Failed.swift +73 -0
  116. package/templates/packs/vapor-api/Sources/App/Error/FailedMiddleware.swift +56 -0
  117. package/templates/packs/vapor-api/Sources/App/Features/Item/AppItem.swift +38 -0
  118. package/templates/packs/vapor-api/Sources/App/Features/Item/Controllers/ItemControllersCrud.swift +41 -0
  119. package/templates/packs/vapor-api/Sources/App/Features/Item/DTO/ItemDTO.swift +22 -0
  120. package/templates/packs/vapor-api/Sources/App/Features/Item/Entities/ItemEntity.swift +30 -0
  121. package/templates/packs/vapor-api/Sources/App/Features/Item/Migrations/ItemMigrationCreate.swift +25 -0
  122. package/templates/packs/vapor-api/Sources/App/Features/Item/Repositories/ItemRepository.swift +32 -0
  123. package/templates/packs/vapor-api/Sources/App/Features/Item/Services/ItemService.swift +57 -0
  124. package/templates/packs/vapor-api/Sources/App/Registry/ControllersRegister.swift +17 -0
  125. package/templates/packs/vapor-api/Sources/App/Registry/MiddlewaresRegister.swift +15 -0
  126. package/templates/packs/vapor-api/Sources/App/Registry/MigrationsRegister.swift +18 -0
  127. package/templates/packs/vapor-api/Sources/Monitoring/Logging/JSONLogHandler.swift +59 -0
  128. package/templates/packs/vapor-api/Sources/Monitoring/Middleware/HTTPLoggingMiddleware.swift +50 -0
  129. package/templates/packs/vapor-api/Sources/Monitoring/Monitoring.swift +110 -0
  130. package/templates/packs/vapor-api/Sources/{{PROJECT_NAME}}Foundation/String+Trimmed.swift +15 -0
  131. package/templates/packs/vapor-api/Tests/AppTests/AppTests.swift +155 -0
  132. package/templates/packs/vapor-api/claude/memory/COMMANDS.md +30 -0
  133. package/templates/packs/vapor-api/docs-architecture/ARCHITECTURE.md +144 -0
  134. package/templates/packs/vapor-api/docs-architecture/CONVENTIONS.md +121 -0
  135. package/templates/packs/vapor-api/docs-architecture/GOTCHAS_LINUX_SWIFT.md +109 -0
  136. package/templates/packs/vapor-api/docs-architecture/OPS.md +102 -0
  137. package/templates/packs/vapor-api/env_dist +29 -0
  138. package/templates/packs/vapor-api/gitignore +7 -0
  139. package/templates/packs/vapor-api/pack.json +11 -0
  140. package/templates/packs/vapor-api/scripts/generate-error-codes.sh +73 -0
  141. package/templates/packs/vapor-api/scripts/validate-env-vars.sh +72 -0
@@ -0,0 +1,191 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import {
3
+ ApiError,
4
+ AuthClient,
5
+ createMemoryContext,
6
+ type HttpMethod,
7
+ type HttpTransport,
8
+ type RequestOptions,
9
+ type TokenPair,
10
+ } from '../src/index';
11
+
12
+ /**
13
+ * THE concurrency regression suite (SDK_CONTRACT.md §3) — non-negotiable.
14
+ *
15
+ * Pins a real production bug: refresh-on-401 without synchronization sent N
16
+ * simultaneous refreshes; the rotating refresh token was single-use, so N−1
17
+ * calls failed and randomly logged users out. The fake API below ENFORCES
18
+ * single-use rotation — a broken single-flight fails on behavior, not just
19
+ * on call counts.
20
+ */
21
+
22
+ const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
23
+
24
+ interface FakeApiOptions {
25
+ refreshDelayMs?: number;
26
+ failRefresh?: boolean;
27
+ hangRefresh?: boolean;
28
+ }
29
+
30
+ function createFakeApi(options: FakeApiOptions = {}) {
31
+ let validAccess = 'access-0';
32
+ let validRefresh = 'refresh-1';
33
+ let refreshCalls = 0;
34
+
35
+ const transport: HttpTransport = {
36
+ async request<T>(method: HttpMethod, path: string, reqOptions: RequestOptions = {}): Promise<T> {
37
+ if (method === 'POST' && path === '/auth/refresh') {
38
+ refreshCalls += 1;
39
+ if (options.hangRefresh) return new Promise<T>(() => {}); // never settles
40
+ await delay(options.refreshDelayMs ?? 20);
41
+ const sent = (reqOptions.body as { refreshToken?: string } | undefined)?.refreshToken;
42
+ if (options.failRefresh || sent !== validRefresh) {
43
+ // single-use enforcement: a second refresh with the same token is rejected
44
+ throw ApiError.fromResponse(
45
+ 401,
46
+ JSON.stringify({ code: 401, name: 'Unauthorized', description: 'refresh token already used' }),
47
+ );
48
+ }
49
+ validRefresh = `refresh-${refreshCalls + 1}`; // rotate: previous token is now burnt
50
+ validAccess = `access-${refreshCalls}`;
51
+ return { accessToken: validAccess, refreshToken: validRefresh } as T;
52
+ }
53
+ if (reqOptions.token !== validAccess) {
54
+ throw ApiError.fromResponse(401, 'token expired');
55
+ }
56
+ return { path, ok: true } as T;
57
+ },
58
+ };
59
+
60
+ return {
61
+ transport,
62
+ refreshCalls: () => refreshCalls,
63
+ currentRefreshToken: () => validRefresh,
64
+ };
65
+ }
66
+
67
+ function createClient(api: ReturnType<typeof createFakeApi>, refreshTimeoutMs?: number) {
68
+ const context = createMemoryContext();
69
+ const auth = new AuthClient(api.transport, context, { refreshTimeoutMs });
70
+ // Session as left by a login flow, with an access token that has since expired:
71
+ auth.setTokens({ accessToken: 'expired-access', refreshToken: 'refresh-1' } satisfies TokenPair);
72
+ return auth;
73
+ }
74
+
75
+ describe('single-flight token refresh', () => {
76
+ test('N concurrent 401s trigger exactly ONE refresh call', async () => {
77
+ const api = createFakeApi();
78
+ const auth = createClient(api);
79
+ const N = 8;
80
+
81
+ const results = await Promise.all(
82
+ Array.from({ length: N }, (_, i) => auth.request<{ path: string; ok: boolean }>('GET', `/items/${i}`)),
83
+ );
84
+
85
+ // every request recovered and succeeded after the shared refresh
86
+ expect(results).toHaveLength(N);
87
+ results.forEach((result, i) => expect(result).toEqual({ path: `/items/${i}`, ok: true }));
88
+ // …through exactly one refresh call (single-use token: a second call would have thrown)
89
+ expect(api.refreshCalls()).toBe(1);
90
+ // and the rotated pair landed in storage
91
+ expect(api.currentRefreshToken()).toBe('refresh-2');
92
+ expect(auth.getAccessToken()).toBe('access-1');
93
+ });
94
+
95
+ test('a 401 refresh rejection clears tokens and rejects ALL waiters', async () => {
96
+ // failRefresh makes the fake API answer the refresh with a 401 (a real auth rejection:
97
+ // the refresh token is no longer valid) — the session is genuinely dead, drop it.
98
+ const api = createFakeApi({ failRefresh: true });
99
+ const auth = createClient(api);
100
+
101
+ const results = await Promise.allSettled([
102
+ auth.request('GET', '/endpoint1'),
103
+ auth.request('GET', '/endpoint2'),
104
+ ]);
105
+
106
+ expect(results[0].status).toBe('rejected');
107
+ expect(results[1].status).toBe('rejected');
108
+ expect(api.refreshCalls()).toBe(1); // failure was shared, not retried per-waiter
109
+ expect(auth.getAccessToken()).toBeNull(); // 401 = auth rejection → tokens cleared
110
+ });
111
+
112
+ test('refresh timeout rejects waiters with RefreshTimeout but KEEPS tokens', async () => {
113
+ // A timeout is a transport failure, not a verdict on the refresh token. Clearing tokens
114
+ // here would force a logout over a flaky/slow network (JP finding #4) — keep the session
115
+ // so a later retry can succeed once connectivity returns.
116
+ const api = createFakeApi({ hangRefresh: true });
117
+ const auth = createClient(api, 25);
118
+
119
+ const results = await Promise.allSettled([
120
+ auth.request('GET', '/endpoint1'),
121
+ auth.request('GET', '/endpoint2'),
122
+ ]);
123
+
124
+ for (const result of results) {
125
+ expect(result.status).toBe('rejected');
126
+ if (result.status === 'rejected') {
127
+ expect(result.reason).toBeInstanceOf(ApiError);
128
+ expect((result.reason as ApiError).errorName).toBe('RefreshTimeout');
129
+ expect((result.reason as ApiError).statusCode).toBe(408);
130
+ }
131
+ }
132
+ expect(api.refreshCalls()).toBe(1);
133
+ expect(auth.getAccessToken()).toBe('expired-access'); // transport failure → tokens kept
134
+ });
135
+
136
+ test('a transport failure (network error) rejects waiters but KEEPS tokens', async () => {
137
+ // The refresh request never reaches the server (offline/DNS/refused): a raw Error, not an
138
+ // ApiError. Waiters fail, but the session is untouched — no gratuitous forced logout.
139
+ const transport: HttpTransport = {
140
+ async request<T>(method: HttpMethod, path: string, reqOptions: RequestOptions = {}): Promise<T> {
141
+ if (method === 'POST' && path === '/auth/refresh') {
142
+ throw new TypeError('fetch failed'); // what Node/undici throws when the host is unreachable
143
+ }
144
+ if (reqOptions.token !== 'access-0') throw ApiError.fromResponse(401, 'token expired');
145
+ return { ok: true } as T;
146
+ },
147
+ };
148
+ const context = createMemoryContext();
149
+ const auth = new AuthClient(transport, context);
150
+ auth.setTokens({ accessToken: 'expired-access', refreshToken: 'refresh-1' });
151
+
152
+ const results = await Promise.allSettled([
153
+ auth.request('GET', '/endpoint1'),
154
+ auth.request('GET', '/endpoint2'),
155
+ ]);
156
+
157
+ expect(results[0].status).toBe('rejected');
158
+ expect(results[1].status).toBe('rejected');
159
+ expect(auth.getAccessToken()).toBe('expired-access'); // network failure → tokens kept
160
+ });
161
+
162
+ test('a valid access token never triggers a refresh', async () => {
163
+ const api = createFakeApi();
164
+ const auth = new AuthClient(api.transport, createMemoryContext());
165
+ auth.setTokens({ accessToken: 'access-0', refreshToken: 'refresh-1' });
166
+
167
+ const result = await auth.request<{ ok: boolean }>('GET', '/me');
168
+
169
+ expect(result.ok).toBe(true);
170
+ expect(api.refreshCalls()).toBe(0);
171
+ });
172
+
173
+ test('non-401 errors propagate untouched, without refreshing', async () => {
174
+ const transport: HttpTransport = {
175
+ request: async () => {
176
+ throw ApiError.fromResponse(
177
+ 409,
178
+ JSON.stringify({ code: 4090, name: 'Conflict', description: 'already exists' }),
179
+ );
180
+ },
181
+ };
182
+ const auth = new AuthClient(transport, createMemoryContext());
183
+ auth.setTokens({ accessToken: 'a', refreshToken: 'r' });
184
+
185
+ await expect(auth.request('POST', '/teams', { name: 'x' })).rejects.toMatchObject({
186
+ statusCode: 409,
187
+ errorName: 'Conflict',
188
+ code: 4090,
189
+ });
190
+ });
191
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM"],
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "verbatimModuleSyntax": true,
10
+ "skipLibCheck": true,
11
+ "noEmit": true,
12
+ "types": []
13
+ },
14
+ "include": ["src", "tests", "tsup.config.ts", "vitest.config.ts"]
15
+ }
@@ -0,0 +1,22 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ /**
4
+ * Two entries, matching the exports map:
5
+ * "." → dist/index.{js,cjs,d.ts,d.cts}
6
+ * "./types" → dist/types/index.{js,cjs,d.ts,d.cts} (types-only subpath)
7
+ *
8
+ * All sources are plain .ts — never author .d.ts files in src/ (declaration
9
+ * generators skip them silently; see docs-architecture/CONVENTIONS_TS.md §1).
10
+ * scripts/verify-dist.mjs gates the build on the emitted declarations.
11
+ */
12
+ export default defineConfig({
13
+ entry: {
14
+ index: 'src/index.ts',
15
+ 'types/index': 'src/types/index.ts',
16
+ },
17
+ format: ['esm', 'cjs'],
18
+ dts: true,
19
+ sourcemap: true,
20
+ clean: true,
21
+ target: 'es2022',
22
+ });
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['tests/**/*.test.ts'],
7
+ },
8
+ });
@@ -0,0 +1,73 @@
1
+ # {{PROJECT_NAME}} — Claude Code Operating Manual
2
+
3
+ This project was scaffolded by **AppForge** (pack: vapor-api): a Claude-Code-first
4
+ server-side Swift architecture extracted from production APIs. You (Claude) are the team
5
+ lead AND the primary developer. Follow this manual exactly — it encodes hard-won lessons,
6
+ not preferences.
7
+
8
+ ## Identity
9
+ - Service: **{{PROJECT_NAME}}** · Identifier: `{{BUNDLE_ID}}` · Swift 6 (strict concurrency)
10
+ - Stack: Vapor 4 + Fluent + PostgreSQL (tests run on in-memory SQLite — zero external services)
11
+ - **Deployment target is Linux (Docker).** macOS-green is NOT done — Linux behaves differently
12
+ (see `GOTCHAS_LINUX_SWIFT.md`).
13
+
14
+ ## Session protocol (MANDATORY)
15
+ 1. **Session start**: run the `restore-context` skill — read `.claude/memory/*.md` before doing anything. Never invent project facts.
16
+ 2. **Empty project / new idea**: run the `kickoff` skill — it interviews the user, writes the PRD, plans slices, then builds autonomously.
17
+ 3. **After significant work**: update `.claude/memory/PROJECT_STATE.md` (and DECISIONS/NEXT_STEPS when relevant) — `save-context` skill.
18
+
19
+ ## Architecture (read the docs before coding)
20
+ The knowledge base lives in `docs-architecture/`. Read the relevant doc BEFORE touching that area:
21
+
22
+ | You are about to… | Read first |
23
+ |---|---|
24
+ | understand the layer model (stack-agnostic) | `ARCHITECTURE_PRINCIPLES.md` |
25
+ | plan/deliver slices, validate, update memory | `DELIVERY.md` |
26
+ | product spans repos (API + SDK + clients) | `MULTI_REPO_CONTRACT.md` |
27
+ | ship/consume a typed SDK | `SDK_CONTRACT.md` |
28
+ | accept user-supplied URLs (webhooks…) | `SECURITY_USER_URLS.md` |
29
+ | write any documentation | `DOCS_PLACEMENT.md` |
30
+ | add caching/feature flags/auth shortcuts | `ANTI_PATTERNS.md` |
31
+ | add/move any file, create a feature module | `ARCHITECTURE.md` |
32
+ | write any Swift code (errors, env vars, DTOs, registration) | `CONVENTIONS.md` |
33
+ | logging, metrics, Docker, deploy, runtime config | `OPS.md` |
34
+ | HTTP clients, background tasks, migrations, anything Linux | `GOTCHAS_LINUX_SWIFT.md` |
35
+
36
+ Layer summary (universal contract in ARCHITECTURE_PRINCIPLES.md, Swift mapping in ARCHITECTURE.md):
37
+ L0 `{{PROJECT_NAME}}Foundation` target (pure primitives) · L1 `Monitoring` target (logs/metrics) ·
38
+ L2 feature `Repositories/` + `Migrations/` + external client targets · L3 feature `Services/` + `Entities/`
39
+ (**business logic lives in services, never repositories**) · L5 `Controllers/` + `DTO/` + `Configure/`.
40
+ Imports point downward only; SPM targets enforce the big walls, folders + grep enforce the rest.
41
+
42
+ ## Non-negotiable rules
43
+ - **Typed errors only**: feature code throws `App.Failed.*` cases — never raw `Abort`, never stringly
44
+ errors. `docs/ERROR_CODES.md` is GENERATED from the enum (`./scripts/generate-error-codes.sh`),
45
+ never hand-written.
46
+ - **Env discipline**: every environment variable is a case of `AppConfig.Key` (typed, fail-fast at
47
+ boot) AND a line in `env_dist` AND present in every deploy manifest. `./scripts/validate-env-vars.sh`
48
+ must pass before any commit that touches config.
49
+ - **ONE HTTP client**: `app.http.client.shared` (AsyncHTTPClient), injected into whatever needs it.
50
+ `URLSession` is FORBIDDEN — it crashes on Linux (see GOTCHAS_LINUX_SWIFT.md).
51
+ - **Migration order is load-bearing**: new migrations are registered explicitly in `configure.swift`,
52
+ positioned after every table they reference. Verify on a scratch database, not your incremental dev DB.
53
+ - **Log level comes from the environment** (`--log` flag or `LOG_LEVEL`), never hardcoded. JSON logs
54
+ in production, text in development — already wired in `entrypoint.swift`.
55
+ - **Never claim done without proof**: `swift test` green + (for endpoints) a curl/test response you
56
+ actually read. Before shipping: run the test suite inside a Linux container (command in COMMANDS.md).
57
+ - **Memory is law**: contradictions between memory files and code → code wins, then fix the memory file.
58
+
59
+ ## Build commands
60
+ ```bash
61
+ swift build # compile all targets
62
+ swift test # full suite — boots the app on in-memory SQLite
63
+ swift run App serve --hostname 127.0.0.1 --port 8080 # needs .env + PostgreSQL
64
+ curl -s localhost:8080/health # liveness proof
65
+
66
+ # Linux gate (the real deployment target) — run before shipping:
67
+ docker run --rm -v "$PWD:/src" -w /src swift:6.2-noble swift test
68
+ ```
69
+ More (DB bootstrap, scripts, Docker image) in `.claude/memory/COMMANDS.md`.
70
+
71
+ ## Git
72
+ - Never push without explicit user approval. Feature branches; commit format `add/update/fix(scope) - description`.
73
+ - No AI attribution in commits or file headers.
@@ -0,0 +1,80 @@
1
+ # ================================
2
+ # Build image
3
+ # ================================
4
+ FROM swift:6.2-noble AS build
5
+
6
+ # OS updates + jemalloc headers (server-grade allocator, linked below)
7
+ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
8
+ && apt-get -q update \
9
+ && apt-get -q dist-upgrade -y \
10
+ && apt-get install -y libjemalloc-dev
11
+
12
+ WORKDIR /build
13
+
14
+ # Resolve dependencies first: this layer is cached and reused as long as
15
+ # Package.swift / Package.resolved do not change.
16
+ COPY ./Package.* ./
17
+ RUN if [ -f ./Package.resolved ]; then \
18
+ swift package resolve --force-resolved-versions; \
19
+ else \
20
+ swift package resolve; \
21
+ fi
22
+
23
+ COPY . .
24
+
25
+ # Release build: static Swift stdlib (no runtime needed in the run image) + jemalloc
26
+ # (measurably lower fragmentation under sustained load than glibc malloc).
27
+ # N.B.: the STATIC jemalloc is incompatible with the static Swift runtime — link dynamic.
28
+ RUN swift build -c release \
29
+ --static-swift-stdlib \
30
+ -Xlinker -ljemalloc
31
+
32
+ WORKDIR /staging
33
+
34
+ # Main executable
35
+ RUN cp "$(swift build --package-path /build -c release --show-bin-path)/App" ./
36
+
37
+ # Static backtracer: readable crash reports in production containers.
38
+ RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
39
+
40
+ # Resources bundled by SPM
41
+ RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
42
+
43
+ # Public/Resources directories if present — read-only by default.
44
+ RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true
45
+ RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true
46
+
47
+ # ================================
48
+ # Run image
49
+ # ================================
50
+ FROM ubuntu:noble
51
+
52
+ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
53
+ && apt-get -q update \
54
+ && apt-get -q dist-upgrade -y \
55
+ && apt-get -q install -y \
56
+ libjemalloc2 \
57
+ ca-certificates \
58
+ tzdata \
59
+ && rm -r /var/lib/apt/lists/*
60
+
61
+ # Non-root user — the API never runs as root.
62
+ RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor
63
+
64
+ WORKDIR /app
65
+
66
+ COPY --from=build --chown=vapor:vapor /staging /app
67
+
68
+ # Crash reporter configuration (pairs with the static backtracer above).
69
+ ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static
70
+
71
+ # Log level is RUNTIME configuration — overridable at `docker run`, never hardcoded.
72
+ ARG LOG_LEVEL=info
73
+ ENV LOG_LEVEL=${LOG_LEVEL}
74
+
75
+ USER vapor:vapor
76
+
77
+ EXPOSE 8080
78
+
79
+ ENTRYPOINT ["./App"]
80
+ CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]
@@ -0,0 +1,68 @@
1
+ // swift-tools-version:6.0
2
+ import PackageDescription
3
+
4
+ let swiftSettings: [SwiftSetting] = [
5
+ .enableUpcomingFeature("ExistentialAny"),
6
+ ]
7
+
8
+ let package = Package(
9
+ name: "{{PROJECT_NAME}}",
10
+ platforms: [
11
+ .macOS(.v13)
12
+ ],
13
+ dependencies: [
14
+ // 💧 Server-side Swift web framework.
15
+ .package(url: "https://github.com/vapor/vapor.git", from: "4.115.0"),
16
+ // 🗄 ORM.
17
+ .package(url: "https://github.com/vapor/fluent.git", from: "4.12.0"),
18
+ // 🐘 PostgreSQL driver (production database).
19
+ .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.10.0"),
20
+ // 🧪 SQLite driver (in-memory database for the test environment ONLY).
21
+ .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.8.0"),
22
+ // 📈 Prometheus metrics backend for swift-metrics.
23
+ .package(url: "https://github.com/swift-server/swift-prometheus.git", from: "2.0.0"),
24
+ ],
25
+ targets: [
26
+ // L0 FOUNDATION — pure Swift primitives. Zero dependencies; builds & tests standalone.
27
+ .target(
28
+ name: "{{PROJECT_NAME}}Foundation",
29
+ swiftSettings: swiftSettings
30
+ ),
31
+
32
+ // L1 OPS — operational plumbing: structured JSON logs, HTTP timing, metrics registry.
33
+ // Imports the web framework as infrastructure, but NEVER any app code.
34
+ .target(
35
+ name: "Monitoring",
36
+ dependencies: [
37
+ .product(name: "Vapor", package: "vapor"),
38
+ .product(name: "Prometheus", package: "swift-prometheus"),
39
+ ],
40
+ swiftSettings: swiftSettings
41
+ ),
42
+
43
+ // L2–L5 — feature modules (Entities/Migrations/Repositories/Services/DTO/Controllers),
44
+ // error contract, configuration and bootstrap. Layering inside this target is
45
+ // folder-enforced — see docs-architecture/ARCHITECTURE.md.
46
+ .executableTarget(
47
+ name: "App",
48
+ dependencies: [
49
+ .product(name: "Vapor", package: "vapor"),
50
+ .product(name: "Fluent", package: "fluent"),
51
+ .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
52
+ .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
53
+ .target(name: "{{PROJECT_NAME}}Foundation"),
54
+ .target(name: "Monitoring"),
55
+ ],
56
+ swiftSettings: swiftSettings
57
+ ),
58
+
59
+ .testTarget(
60
+ name: "AppTests",
61
+ dependencies: [
62
+ .target(name: "App"),
63
+ .product(name: "VaporTesting", package: "vapor"),
64
+ ],
65
+ swiftSettings: swiftSettings
66
+ ),
67
+ ]
68
+ )
@@ -0,0 +1,5 @@
1
+ import Foundation
2
+
3
+ /// Root namespace. Every feature module extends it: `extension App { enum Item { … } }`.
4
+ /// Call sites read as domain language: `App.Item.Service`, `App.Failed.BadRequest`.
5
+ enum App {}
@@ -0,0 +1,108 @@
1
+ import Vapor
2
+
3
+ /// Typed, fail-fast runtime configuration.
4
+ ///
5
+ /// THE rule: every value the app reads from the environment goes through `Key` — never
6
+ /// call `Environment.get` anywhere else. A missing variable kills the boot with a named
7
+ /// error instead of surfacing as a nil three requests later.
8
+ ///
9
+ /// Cross-check discipline: each `Key` case must exist in `env_dist` AND in every deploy
10
+ /// manifest (compose files, CI workflows). `scripts/validate-env-vars.sh` enforces it.
11
+ struct AppConfig: Sendable {
12
+ let databaseHost: String
13
+ let databasePort: Int
14
+ let databaseUsername: String
15
+ let databasePassword: String
16
+ let databaseName: String
17
+ let allowedOrigin: String
18
+ let monitoringEnabled: Bool
19
+ let metricsToken: String
20
+
21
+ static func load(for environment: Environment) -> AppConfig {
22
+ // `.testing` is EXPLICIT — tests pass it to `Application.make(.testing)`.
23
+ // Never sniff the test runner from process arguments or env leftovers.
24
+ guard environment != .testing else { return .testing }
25
+
26
+ return AppConfig(
27
+ databaseHost: Key.DATABASE_HOST.get,
28
+ databasePort: Key.DATABASE_PORT.getInt,
29
+ databaseUsername: Key.DATABASE_USERNAME.get,
30
+ databasePassword: Key.DATABASE_PASSWORD.get,
31
+ databaseName: Key.DATABASE_NAME.get,
32
+ allowedOrigin: Key.ALLOWED_ORIGIN.get,
33
+ monitoringEnabled: Key.MONITORING_ENABLED.getBool,
34
+ metricsToken: Key.METRICS_TOKEN.get
35
+ )
36
+ }
37
+
38
+ /// Test fixture — the database values are unused (testing runs on in-memory SQLite),
39
+ /// monitoring is on so the /metrics contract stays under test.
40
+ static let testing = AppConfig(
41
+ databaseHost: "unused-in-testing",
42
+ databasePort: 0,
43
+ databaseUsername: "unused-in-testing",
44
+ databasePassword: "unused-in-testing",
45
+ databaseName: "unused-in-testing",
46
+ allowedOrigin: "http://localhost:3000",
47
+ monitoringEnabled: true,
48
+ metricsToken: "test-metrics-token"
49
+ )
50
+ }
51
+
52
+ extension AppConfig {
53
+ /// One case per environment variable — `CaseIterable` so the validation script can
54
+ /// grep this enum and cross-check `env_dist` + deploy manifests.
55
+ enum Key: String, CaseIterable {
56
+ case DATABASE_HOST
57
+ case DATABASE_PORT
58
+ case DATABASE_USERNAME
59
+ case DATABASE_PASSWORD
60
+ case DATABASE_NAME
61
+ case ALLOWED_ORIGIN
62
+ case MONITORING_ENABLED
63
+ case METRICS_TOKEN
64
+
65
+ /// Fail fast at boot — a missing variable must kill the process by name.
66
+ var get: String {
67
+ guard let value = Environment.get(rawValue) else {
68
+ fatalError("""
69
+ Missing environment variable \(rawValue) — add it to .env AND env_dist \
70
+ AND every deploy manifest, then run scripts/validate-env-vars.sh.
71
+ """)
72
+ }
73
+ return value
74
+ }
75
+
76
+ var getInt: Int {
77
+ guard let value = Int(get) else {
78
+ fatalError("Environment variable \(rawValue) must be an integer (got '\(get)')")
79
+ }
80
+ return value
81
+ }
82
+
83
+ var getBool: Bool {
84
+ ["true", "1", "yes"].contains(get.lowercased())
85
+ }
86
+ }
87
+ }
88
+
89
+ // MARK: - Dependency injection via Application.storage
90
+
91
+ extension AppConfig {
92
+ struct StorageKey: Vapor.StorageKey {
93
+ typealias Value = AppConfig
94
+ }
95
+ }
96
+
97
+ extension Application {
98
+ var config: AppConfig {
99
+ guard let config = storage[AppConfig.StorageKey.self] else {
100
+ fatalError("AppConfig not loaded — configure(_:) must store it before first use")
101
+ }
102
+ return config
103
+ }
104
+ }
105
+
106
+ extension Request {
107
+ var config: AppConfig { application.config }
108
+ }
@@ -0,0 +1,74 @@
1
+ import Fluent
2
+ import FluentPostgresDriver
3
+ import FluentSQLiteDriver
4
+ import Vapor
5
+ import Monitoring
6
+
7
+ /// Composition root. Order matters: config → database → migrations → middlewares →
8
+ /// monitoring → routes → migrate.
9
+ public func configure(_ app: Application) async throws {
10
+ let config = AppConfig.load(for: app.environment)
11
+ app.storage[AppConfig.StorageKey.self] = config
12
+
13
+ try databasesInit(app, config: config)
14
+ migrationsInit(app)
15
+ middlewaresInit(app, config: config)
16
+
17
+ try configureMonitoring(
18
+ app: app,
19
+ enabled: config.monitoringEnabled,
20
+ metricsToken: config.metricsToken
21
+ )
22
+
23
+ try routes(app)
24
+
25
+ try await app.autoMigrate()
26
+ }
27
+
28
+ private func databasesInit(_ app: Application, config: AppConfig) throws {
29
+ if app.environment == .testing {
30
+ // In-memory SQLite: tests boot the FULL stack (migrations included) with zero
31
+ // external services. PostgreSQL-specific behavior still needs a CI job against
32
+ // a real PostgreSQL before release.
33
+ app.databases.use(.sqlite(.memory), as: .sqlite)
34
+ } else {
35
+ app.databases.use(DatabaseConfigurationFactory.postgres(configuration: .init(
36
+ hostname: config.databaseHost,
37
+ port: config.databasePort,
38
+ username: config.databaseUsername,
39
+ password: config.databasePassword,
40
+ database: config.databaseName,
41
+ tls: .prefer(try .init(configuration: .clientDefault))
42
+ ), maxConnectionsPerEventLoop: 2, connectionPoolTimeout: .seconds(60)), as: .psql)
43
+ }
44
+ }
45
+
46
+ private func migrationsInit(_ app: Application) {
47
+ // ⚠️ ORDER IS LOAD-BEARING. Fluent runs migrations in registration order; a schema
48
+ // that references another table (foreign key) must be registered AFTER that table.
49
+ // New feature → append its register() call here, positioned by its foreign keys,
50
+ // and verify on a SCRATCH database (docs-architecture/GOTCHAS_LINUX_SWIFT.md).
51
+ App.Item.Migrations.register(app: app)
52
+ }
53
+
54
+ private func middlewaresInit(_ app: Application, config: AppConfig) {
55
+ // Rebuild the stack from scratch: drops Vapor's default RouteLoggingMiddleware,
56
+ // replaced by HTTPLoggingMiddleware (duration + probe-path exclusion).
57
+ app.middleware = .init()
58
+
59
+ app.middleware.use(CORSMiddleware(configuration: .init(
60
+ allowedOrigin: .custom(config.allowedOrigin),
61
+ allowedMethods: [.GET, .POST, .PUT, .PATCH, .DELETE, .OPTIONS],
62
+ allowedHeaders: [.authorization, .contentType, .accept, .origin, .xRequestedWith],
63
+ allowCredentials: true,
64
+ exposedHeaders: [.authorization, .contentType]
65
+ )))
66
+
67
+ app.middleware.use(HTTPLoggingMiddleware())
68
+
69
+ // Outer catch-all (Abort + anything untyped)…
70
+ app.middleware.use(ErrorMiddleware.default(environment: app.environment))
71
+ // …then the typed-error contract closest to the routes:
72
+ // CustomError → {code, name, description} JSON (see CONVENTIONS.md).
73
+ App.Failed.Middlewares.register(app: app)
74
+ }