@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.
- package/LICENSE +32 -0
- package/README.md +99 -0
- package/bin/cli.js +371 -0
- package/bin/cli.test.js +91 -0
- package/package.json +43 -0
- package/templates/core/CLAUDE.md +36 -0
- package/templates/core/claude/memory/ARCHITECTURE.md +20 -0
- package/templates/core/claude/memory/COMMANDS.md +13 -0
- package/templates/core/claude/memory/DECISIONS.md +5 -0
- package/templates/core/claude/memory/NEXT_STEPS.md +11 -0
- package/templates/core/claude/memory/PROJECT_STATE.md +24 -0
- package/templates/core/claude/skills/kickoff/SKILL.md +84 -0
- package/templates/core/claude/skills/product-owner/SKILL.md +58 -0
- package/templates/core/claude/skills/restore-context/SKILL.md +29 -0
- package/templates/core/claude/skills/save-context/SKILL.md +35 -0
- package/templates/core/docs-architecture/ANTI_PATTERNS.md +180 -0
- package/templates/core/docs-architecture/ARCHITECTURE_PRINCIPLES.md +134 -0
- package/templates/core/docs-architecture/DELIVERY.md +68 -0
- package/templates/core/docs-architecture/DOCS_PLACEMENT.md +151 -0
- package/templates/core/docs-architecture/MULTI_REPO_CONTRACT.md +158 -0
- package/templates/core/docs-architecture/SDK_CONTRACT.md +214 -0
- package/templates/core/docs-architecture/SECURITY_USER_URLS.md +152 -0
- package/templates/core/gitignore +15 -0
- package/templates/core/mcp.json +8 -0
- package/templates/packs/nuxt-web/CLAUDE.md +74 -0
- package/templates/packs/nuxt-web/app/app.vue +5 -0
- package/templates/packs/nuxt-web/app/assets/css/main.css +18 -0
- package/templates/packs/nuxt-web/app/assets/css/tokens.css +41 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/components/DSButton.vue +70 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/index.ts +4 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/tests/DSButton.spec.ts +34 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/types/dsButton.ts +5 -0
- package/templates/packs/nuxt-web/app/domain/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/features/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/pages/index.vue +36 -0
- package/templates/packs/nuxt-web/app/utils/.gitkeep +0 -0
- package/templates/packs/nuxt-web/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/nuxt-web/docs-architecture/ARCHITECTURE.md +169 -0
- package/templates/packs/nuxt-web/docs-architecture/CONVENTIONS.md +140 -0
- package/templates/packs/nuxt-web/docs-architecture/I18N.md +102 -0
- package/templates/packs/nuxt-web/docs-architecture/OPS_WEB.md +176 -0
- package/templates/packs/nuxt-web/docs-architecture/SEO_AND_ROUTING.md +118 -0
- package/templates/packs/nuxt-web/gitignore +18 -0
- package/templates/packs/nuxt-web/nuxt.config.ts +49 -0
- package/templates/packs/nuxt-web/pack.json +11 -0
- package/templates/packs/nuxt-web/package.json +31 -0
- package/templates/packs/nuxt-web/playwright.config.ts +39 -0
- package/templates/packs/nuxt-web/server/api/health.get.ts +7 -0
- package/templates/packs/nuxt-web/tests/e2e/home.spec.ts +19 -0
- package/templates/packs/nuxt-web/tsconfig.json +4 -0
- package/templates/packs/nuxt-web/vitest.config.ts +23 -0
- package/templates/packs/swift-ios/CLAUDE.md +64 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Package.swift +21 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Sources/DataLayer/DataLayer.swift +11 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Package.swift +20 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Domain/SampleItem.swift +15 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Engine/SampleEngine.swift +14 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Repository/SampleItemRepository.swift +27 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Tests/{{PROJECT_NAME}}CoreTests/SampleEngineTests.swift +32 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Package.swift +17 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Color+DS.swift +18 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Components/DSCard.swift +22 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DS.swift +36 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DSFont.swift +26 -0
- package/templates/packs/swift-ios/claude/memory/COMMANDS.md +18 -0
- package/templates/packs/swift-ios/docs-architecture/ARCHITECTURE.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/CLOUDKIT_GUIDE.md +224 -0
- package/templates/packs/swift-ios/docs-architecture/CONVENTIONS.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/DESIGN_SYSTEM.md +272 -0
- package/templates/packs/swift-ios/docs-architecture/NAVIGATION.md +241 -0
- package/templates/packs/swift-ios/docs-architecture/TESTING.md +176 -0
- package/templates/packs/swift-ios/docs-architecture/WORKFLOW.md +165 -0
- package/templates/packs/swift-ios/github/workflows/ci.yml +48 -0
- package/templates/packs/swift-ios/gitignore +5 -0
- package/templates/packs/swift-ios/mcp.json +8 -0
- package/templates/packs/swift-ios/pack.json +11 -0
- package/templates/packs/swift-ios/project.yml +33 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/App.swift +32 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/AppNamespace.swift +4 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Module/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Store/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Tools/.gitkeep +0 -0
- package/templates/packs/ts-sdk/CHANGELOG.md +9 -0
- package/templates/packs/ts-sdk/CLAUDE.md +72 -0
- package/templates/packs/ts-sdk/MIGRATION.md +28 -0
- package/templates/packs/ts-sdk/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/ts-sdk/docs-architecture/ARCHITECTURE.md +132 -0
- package/templates/packs/ts-sdk/docs-architecture/CONVENTIONS_TS.md +152 -0
- package/templates/packs/ts-sdk/gitignore +6 -0
- package/templates/packs/ts-sdk/pack.json +11 -0
- package/templates/packs/ts-sdk/package.json +55 -0
- package/templates/packs/ts-sdk/scripts/verify-dist.mjs +67 -0
- package/templates/packs/ts-sdk/src/clients/AuthClient.ts +168 -0
- package/templates/packs/ts-sdk/src/core/HttpClient.ts +85 -0
- package/templates/packs/ts-sdk/src/core/Logger.ts +27 -0
- package/templates/packs/ts-sdk/src/core/SDKContext.ts +40 -0
- package/templates/packs/ts-sdk/src/core/withTimeout.ts +19 -0
- package/templates/packs/ts-sdk/src/errors/ApiError.ts +93 -0
- package/templates/packs/ts-sdk/src/index.ts +62 -0
- package/templates/packs/ts-sdk/src/types/index.ts +33 -0
- package/templates/packs/ts-sdk/tests/apiError.test.ts +58 -0
- package/templates/packs/ts-sdk/tests/httpClient.test.ts +60 -0
- package/templates/packs/ts-sdk/tests/singleFlight.test.ts +191 -0
- package/templates/packs/ts-sdk/tsconfig.json +15 -0
- package/templates/packs/ts-sdk/tsup.config.ts +22 -0
- package/templates/packs/ts-sdk/vitest.config.ts +8 -0
- package/templates/packs/vapor-api/CLAUDE.md +73 -0
- package/templates/packs/vapor-api/Dockerfile +80 -0
- package/templates/packs/vapor-api/Package.swift +68 -0
- package/templates/packs/vapor-api/Sources/App/App.swift +5 -0
- package/templates/packs/vapor-api/Sources/App/Configure/AppConfig.swift +108 -0
- package/templates/packs/vapor-api/Sources/App/Configure/configure.swift +74 -0
- package/templates/packs/vapor-api/Sources/App/Configure/entrypoint.swift +47 -0
- package/templates/packs/vapor-api/Sources/App/Configure/routes.swift +21 -0
- package/templates/packs/vapor-api/Sources/App/Error/Failed.swift +73 -0
- package/templates/packs/vapor-api/Sources/App/Error/FailedMiddleware.swift +56 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/AppItem.swift +38 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Controllers/ItemControllersCrud.swift +41 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/DTO/ItemDTO.swift +22 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Entities/ItemEntity.swift +30 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Migrations/ItemMigrationCreate.swift +25 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Repositories/ItemRepository.swift +32 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Services/ItemService.swift +57 -0
- package/templates/packs/vapor-api/Sources/App/Registry/ControllersRegister.swift +17 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MiddlewaresRegister.swift +15 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MigrationsRegister.swift +18 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Logging/JSONLogHandler.swift +59 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Middleware/HTTPLoggingMiddleware.swift +50 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Monitoring.swift +110 -0
- package/templates/packs/vapor-api/Sources/{{PROJECT_NAME}}Foundation/String+Trimmed.swift +15 -0
- package/templates/packs/vapor-api/Tests/AppTests/AppTests.swift +155 -0
- package/templates/packs/vapor-api/claude/memory/COMMANDS.md +30 -0
- package/templates/packs/vapor-api/docs-architecture/ARCHITECTURE.md +144 -0
- package/templates/packs/vapor-api/docs-architecture/CONVENTIONS.md +121 -0
- package/templates/packs/vapor-api/docs-architecture/GOTCHAS_LINUX_SWIFT.md +109 -0
- package/templates/packs/vapor-api/docs-architecture/OPS.md +102 -0
- package/templates/packs/vapor-api/env_dist +29 -0
- package/templates/packs/vapor-api/gitignore +7 -0
- package/templates/packs/vapor-api/pack.json +11 -0
- package/templates/packs/vapor-api/scripts/generate-error-codes.sh +73 -0
- 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,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,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
|
+
}
|