@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,110 @@
1
+ import Foundation
2
+ import Vapor
3
+ import Prometheus
4
+ import Metrics
5
+
6
+ /// Process-global Prometheus registry.
7
+ ///
8
+ /// `MetricsSystem.bootstrap` may only run ONCE per process, while tests boot many
9
+ /// `Application`s — so both the registry and the bootstrap are anchored to the process
10
+ /// (thread-safe lazy `static let`), never to an `Application`.
11
+ public enum MetricsRegistry {
12
+ public static let shared: PrometheusCollectorRegistry = {
13
+ let registry = PrometheusCollectorRegistry()
14
+ MetricsSystem.bootstrap(PrometheusMetricsFactory(registry: registry))
15
+ return registry
16
+ }()
17
+ }
18
+
19
+ extension Application {
20
+ /// Storage key for the Prometheus registry (set when monitoring is enabled).
21
+ public struct PrometheusRegistryKey: StorageKey {
22
+ public typealias Value = PrometheusCollectorRegistry
23
+ }
24
+
25
+ /// Storage key for the /metrics bearer token.
26
+ public struct MetricsTokenKey: StorageKey {
27
+ public typealias Value = String
28
+ }
29
+ }
30
+
31
+ /// Raised at boot when monitoring is enabled but the bearer token is missing/blank.
32
+ /// Surfaced as a named failure instead of a silent /metrics 401 forever (fail-fast doctrine).
33
+ public struct MonitoringConfigurationError: Error, CustomStringConvertible {
34
+ public let description: String
35
+ public init(_ description: String) { self.description = description }
36
+ }
37
+
38
+ /// Wire monitoring into the app. Disabled → no registry in storage → /metrics returns 503.
39
+ ///
40
+ /// Fail fast: with monitoring ENABLED, an empty/blank `metricsToken` would make a
41
+ /// constant-time compare against "" reject every request — /metrics 401 forever, a silent
42
+ /// misconfiguration that contradicts the env-discipline doctrine. We refuse to boot instead.
43
+ public func configureMonitoring(app: Application, enabled: Bool, metricsToken: String) throws {
44
+ guard enabled else {
45
+ app.logger.info("[MONITORING] disabled")
46
+ return
47
+ }
48
+
49
+ guard !metricsToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
50
+ throw MonitoringConfigurationError(
51
+ "MONITORING_ENABLED is true but METRICS_TOKEN is empty — set a non-empty token in "
52
+ + ".env AND env_dist AND every deploy manifest (an empty token leaves /metrics 401 "
53
+ + "forever). Disable monitoring or provide the token, then re-run."
54
+ )
55
+ }
56
+
57
+ app.storage[Application.PrometheusRegistryKey.self] = MetricsRegistry.shared
58
+ app.storage[Application.MetricsTokenKey.self] = metricsToken
59
+
60
+ app.logger.info("[MONITORING] enabled — backend: swift-prometheus")
61
+ }
62
+
63
+ /// The /metrics route handler: bearer-token gated (constant-time compare), emits the
64
+ /// Prometheus text format. Lives here so the App target never imports Prometheus directly.
65
+ public func handleMetricsRequest(_ req: Request) throws -> Response {
66
+ guard let registry = req.application.storage[Application.PrometheusRegistryKey.self],
67
+ let token = req.application.storage[Application.MetricsTokenKey.self] else {
68
+ throw Abort(.serviceUnavailable, reason: "Monitoring not configured")
69
+ }
70
+
71
+ guard let bearer = req.headers.bearerAuthorization else {
72
+ throw Abort(.unauthorized, reason: "Missing authentication token")
73
+ }
74
+
75
+ guard constantTimeCompare(bearer.token, token) else {
76
+ throw Abort(.unauthorized, reason: "Invalid authentication token")
77
+ }
78
+
79
+ var buffer: [UInt8] = []
80
+ registry.emit(into: &buffer)
81
+
82
+ var headers = HTTPHeaders()
83
+ headers.add(name: .contentType, value: "text/plain; version=0.0.4; charset=utf-8")
84
+ return Response(status: .ok, headers: headers, body: .init(string: String(decoding: buffer, as: UTF8.self)))
85
+ }
86
+
87
+ /// Increment the HTTP error counter (called by the typed-error middleware). Counter
88
+ /// emission is an L1 concern — feature code never touches the metrics API directly.
89
+ public func recordHTTPError(status: UInt, errorName: String) {
90
+ Counter(label: "http_errors_total",
91
+ dimensions: [("status", "\(status)"), ("error", errorName)]).increment()
92
+ }
93
+
94
+ /// Constant-time comparison to prevent timing attacks on the bearer token.
95
+ /// Always iterates over the max length of both inputs to avoid leaking length information.
96
+ public func constantTimeCompare(_ lhs: String, _ rhs: String) -> Bool {
97
+ let lhsData = Array(lhs.utf8)
98
+ let rhsData = Array(rhs.utf8)
99
+ let maxLen = max(lhsData.count, rhsData.count)
100
+
101
+ // Length mismatch must still fail, but without an early return.
102
+ var accumulator: UInt8 = lhsData.count == rhsData.count ? 0 : 1
103
+
104
+ for i in 0..<maxLen {
105
+ let lhsByte: UInt8 = i < lhsData.count ? lhsData[i] : 0
106
+ let rhsByte: UInt8 = i < rhsData.count ? rhsData[i] : 0
107
+ accumulator |= lhsByte ^ rhsByte
108
+ }
109
+ return accumulator == 0
110
+ }
@@ -0,0 +1,15 @@
1
+ import Foundation
2
+
3
+ // L0 helper — primitives only. This target depends on nothing; keep it that way.
4
+ public extension String {
5
+ /// Whitespace/newline-trimmed copy. Services normalize user input with this
6
+ /// before persisting (storage rule: names are stored trimmed).
7
+ var trimmed: String {
8
+ trimmingCharacters(in: .whitespacesAndNewlines)
9
+ }
10
+
11
+ /// True when the string is empty after trimming.
12
+ var isBlank: Bool {
13
+ trimmed.isEmpty
14
+ }
15
+ }
@@ -0,0 +1,155 @@
1
+ import Testing
2
+ import VaporTesting
3
+ import Vapor
4
+ @testable import App
5
+ @testable import Monitoring
6
+
7
+ @Suite("{{PROJECT_NAME}} API — boot + contracts")
8
+ struct AppTests {
9
+ /// Boots the FULL app in `.testing` (in-memory SQLite + real migrations + real
10
+ /// middleware stack), runs the body, always shuts down. Each test gets a fresh app
11
+ /// and a fresh database — tests stay order-independent and parallel-safe.
12
+ private func withApp(_ body: (Application) async throws -> Void) async throws {
13
+ let app = try await Application.make(.testing)
14
+ do {
15
+ try await configure(app)
16
+ try await body(app)
17
+ } catch {
18
+ try? await app.asyncShutdown()
19
+ throw error
20
+ }
21
+ try await app.asyncShutdown()
22
+ }
23
+
24
+ @Test("GET /health responds 200")
25
+ func healthCheck() async throws {
26
+ try await withApp { app in
27
+ try await app.testing().test(.GET, "health") { res async in
28
+ #expect(res.status == .ok)
29
+ #expect(res.body.string.contains("ok"))
30
+ }
31
+ }
32
+ }
33
+
34
+ @Test("POST /api/items persists, trims the name (L3 rule), returns the DTO")
35
+ func createItem() async throws {
36
+ try await withApp { app in
37
+ try await app.testing().test(.POST, "api/items", beforeRequest: { req in
38
+ try req.content.encode(App.Item.DTO.Input(name: " First item "))
39
+ }, afterResponse: { res async throws in
40
+ #expect(res.status == .ok)
41
+ let output = try res.content.decode(App.Item.DTO.Output.self)
42
+ #expect(output.name == "First item") // trimming lives in the Service, not the controller
43
+ })
44
+
45
+ try await app.testing().test(.GET, "api/items") { res async throws in
46
+ #expect(res.status == .ok)
47
+ let items = try res.content.decode([App.Item.DTO.Output].self)
48
+ #expect(items.count == 1)
49
+ }
50
+ }
51
+ }
52
+
53
+ @Test("Blank name → typed error contract {code, name, description}")
54
+ func typedErrorContract() async throws {
55
+ try await withApp { app in
56
+ try await app.testing().test(.POST, "api/items", beforeRequest: { req in
57
+ try req.content.encode(App.Item.DTO.Input(name: " "))
58
+ }, afterResponse: { res async throws in
59
+ #expect(res.status == .badRequest)
60
+ let error = try res.content.decode(
61
+ App.Failed.Middlewares.Handler.ErrorDescription.self
62
+ )
63
+ #expect(error.code == 400)
64
+ #expect(error.name == "dataNotValid") // case name = stable public identifier
65
+ })
66
+ }
67
+ }
68
+
69
+ @Test("Duplicate name → 409 dataAlreadyExist")
70
+ func duplicateItem() async throws {
71
+ try await withApp { app in
72
+ for expected in [HTTPStatus.ok, .conflict] {
73
+ try await app.testing().test(.POST, "api/items", beforeRequest: { req in
74
+ try req.content.encode(App.Item.DTO.Input(name: "Twice"))
75
+ }, afterResponse: { res async in
76
+ #expect(res.status == expected)
77
+ })
78
+ }
79
+ }
80
+ }
81
+
82
+ @Test("Monitoring enabled with an EMPTY token fails fast at boot (no silent /metrics 401)")
83
+ func monitoringEmptyTokenFailsFast() async throws {
84
+ let app = try await Application.make(.testing)
85
+ do {
86
+ #expect(throws: MonitoringConfigurationError.self) {
87
+ try configureMonitoring(app: app, enabled: true, metricsToken: "")
88
+ }
89
+ #expect(throws: MonitoringConfigurationError.self) {
90
+ try configureMonitoring(app: app, enabled: true, metricsToken: " ")
91
+ }
92
+ // Disabled → blank token is fine (no metrics endpoint).
93
+ #expect(throws: Never.self) {
94
+ try configureMonitoring(app: app, enabled: false, metricsToken: "")
95
+ }
96
+ } catch {
97
+ try? await app.asyncShutdown()
98
+ throw error
99
+ }
100
+ try await app.asyncShutdown()
101
+ }
102
+
103
+ @Test("Duplicate name under concurrency → exactly one insert, the rest 409 (DB unique constraint)")
104
+ func duplicateNameRace() async throws {
105
+ try await withApp { app in
106
+ // Fire many concurrent creates of the SAME name. With a TOCTOU check-then-insert and
107
+ // no DB constraint, several would slip through; the unique index + typed-409 mapping
108
+ // guarantees exactly one success.
109
+ let name = "RaceMe"
110
+ await withTaskGroup(of: HTTPStatus.self) { group in
111
+ for _ in 0..<8 {
112
+ group.addTask {
113
+ var status: HTTPStatus = .internalServerError
114
+ do {
115
+ try await app.testing().test(.POST, "api/items", beforeRequest: { req in
116
+ try req.content.encode(App.Item.DTO.Input(name: name))
117
+ }, afterResponse: { res async in
118
+ status = res.status
119
+ })
120
+ } catch {
121
+ status = .internalServerError
122
+ }
123
+ return status
124
+ }
125
+ }
126
+ var okCount = 0
127
+ for await status in group {
128
+ if status == .ok { okCount += 1 } else { #expect(status == .conflict) }
129
+ }
130
+ #expect(okCount == 1)
131
+ }
132
+ }
133
+ }
134
+
135
+ @Test("/metrics is bearer-token gated (constant-time compare)")
136
+ func metricsTokenGate() async throws {
137
+ try await withApp { app in
138
+ try await app.testing().test(.GET, "metrics") { res async in
139
+ #expect(res.status == .unauthorized)
140
+ }
141
+
142
+ var wrong = HTTPHeaders()
143
+ wrong.bearerAuthorization = .init(token: "not-the-token")
144
+ try await app.testing().test(.GET, "metrics", headers: wrong) { res async in
145
+ #expect(res.status == .unauthorized)
146
+ }
147
+
148
+ var headers = HTTPHeaders()
149
+ headers.bearerAuthorization = .init(token: "test-metrics-token")
150
+ try await app.testing().test(.GET, "metrics", headers: headers) { res async in
151
+ #expect(res.status == .ok)
152
+ }
153
+ }
154
+ }
155
+ }
@@ -0,0 +1,30 @@
1
+ # {{PROJECT_NAME}} — Commands
2
+
3
+ > Only commands proven to work in THIS project, with exact flags.
4
+
5
+ ## Build & test (fast loop — always first)
6
+ swift build
7
+ swift test
8
+ swift test --filter AppTests # one suite
9
+
10
+ ## Run locally
11
+ cp env_dist .env # once — then fill values (never commit .env)
12
+ docker run -d --name {{PROJECT_NAME}}-db -p 5432:5432 \
13
+ -e POSTGRES_USER=vapor_username -e POSTGRES_PASSWORD=vapor_password \
14
+ -e POSTGRES_DB=vapor_database postgres:16-alpine
15
+ swift run App serve --hostname 127.0.0.1 --port 8080
16
+ swift run App serve --log debug # one-off verbosity bump
17
+ curl -s localhost:8080/health # liveness proof
18
+
19
+ ## Database
20
+ swift run App migrate # run pending migrations manually
21
+ swift run App migrate --revert # revert the last batch
22
+
23
+ ## Quality gates (before any ship)
24
+ bash scripts/validate-env-vars.sh # AppConfig.Key ↔ env_dist ↔ manifests
25
+ bash scripts/generate-error-codes.sh # regenerate docs/ERROR_CODES.md from Failed.swift
26
+ docker run --rm -v "$PWD:/src" -w /src swift:6.2-noble swift test # Linux gate
27
+
28
+ ## Docker image
29
+ docker build -t {{BUNDLE_ID}} .
30
+ docker run --rm -p 8080:8080 --env-file .env {{BUNDLE_ID}}
@@ -0,0 +1,144 @@
1
+ # ARCHITECTURE — Layered Vapor API (Swift 6, server-side)
2
+
3
+ Pattern for Vapor 4 APIs deployed on Linux. The service is assembled from SPM targets
4
+ (compiler-enforced walls) + role folders inside the App target (convention-enforced,
5
+ grep-verifiable). Read `ARCHITECTURE_PRINCIPLES.md` first — this maps it onto Swift.
6
+
7
+ ## 1. Layer Model — Swift instantiation of the universal L0–L5 contract
8
+
9
+ ```
10
+ L5 COMPLETE FEATURES Sources/App/Configure/ entrypoint, configure, routes, AppConfig (composition root)
11
+ Features/<F>/Controllers/ HTTP boundary: routes, decode/encode DTOs
12
+ Features/<F>/DTO/ wire types (Input/Output Content structs)
13
+ L4 SHARED FEATURES created on demand: logic needed by ≥2 feature modules gets its own
14
+ SPM target (e.g. an auth or billing module). Don't pre-create.
15
+ L3 CORE LOGIC Features/<F>/Services/ BUSINESS RULES: validation, normalization,
16
+ typed errors, orchestration
17
+ Features/<F>/Entities/ Fluent models (domain shape + persistence mapping)
18
+ L2 DATA Features/<F>/Repositories/ data access ONLY — answers "what does the DB say"
19
+ Features/<F>/Migrations/ schema changes (append-only)
20
+ Features/<F>/Jobs/ queued/scheduled work (created when needed; calls Services)
21
+ Sources/<X>Client/ one SPM target per external API (AsyncHTTPClient)
22
+ L1 OPS Sources/Monitoring/ SPM target: JSON logs, HTTP timing, metrics registry
23
+ L0 FOUNDATION Sources/{{PROJECT_NAME}}Foundation/ SPM target: pure primitives, ZERO dependencies
24
+ ```
25
+
26
+ Notes on the mapping:
27
+ - **Targets enforce the big walls.** Foundation and Monitoring cannot import App — the
28
+ compiler guarantees it. External-API clients live in their own targets so they build
29
+ and test without booting the app.
30
+ - **Folders enforce the rest.** Inside the App target, layers are roles within a feature
31
+ module. The compiler can't help there, so the rules below must be grep-checkable.
32
+ - **Third-party frameworks are not layers.** Monitoring (L1) imports Vapor as
33
+ infrastructure; the dependency rule governs imports between PROJECT bricks only.
34
+
35
+ ## 2. Dependency Direction Rules
36
+
37
+ | Layer | May use | Must NEVER use | Why |
38
+ |---|---|---|---|
39
+ | Controllers (L5) | Services, DTO, `App.Failed` | Fluent queries, Repositories directly | HTTP translation only — logic in services stays testable without HTTP |
40
+ | Services (L3) | Repositories, Entities, Foundation, `App.Failed` | `Request`, `Response`, HTTP types | Services take plain values; one service = one testable unit |
41
+ | Repositories (L2) | Fluent + Entities | `App.Failed` business errors, Services, validation | A repository reports DB facts; deciding what they MEAN is L3 |
42
+ | Migrations (L2) | Fluent schema builder | Entities' current code for data backfill | A migration must stay valid when the model changes later |
43
+ | Monitoring (L1) | Vapor, metrics libs | anything in App | Reusable across services; no domain knowledge |
44
+ | Foundation (L0) | Swift stdlib + Foundation | everything else | Primitives stay portable and instantly testable |
45
+
46
+ Grep checks (run them when in doubt — empty output = healthy):
47
+ ```bash
48
+ grep -rn "query(on:" Sources/App/Features/*/Controllers/ # controllers bypassing services
49
+ grep -rn "import Vapor" Sources/App/Features/*/Services/ # services seeing HTTP (Request/Response live in Vapor)
50
+ grep -rn "App.Failed" Sources/App/Features/*/Repositories/ # repositories making decisions
51
+ grep -rn "URLSession" Sources/ # forbidden — GOTCHAS_LINUX_SWIFT.md
52
+ ```
53
+
54
+ ## 3. Feature modules — the unit of growth
55
+
56
+ One folder per feature under `Sources/App/Features/`, everything colocated by role.
57
+ `Features/Item/` is the REFERENCE module — copy its shape:
58
+
59
+ ```
60
+ Features/Item/
61
+ ├── AppItem.swift namespace + registration surfaces (the module's front door)
62
+ ├── Controllers/ RouteCollections
63
+ ├── DTO/ Input/Output Content structs
64
+ ├── Entities/ Fluent models
65
+ ├── Migrations/ append-only schema changes
66
+ ├── Repositories/ data access
67
+ └── Services/ business logic
68
+ ```
69
+
70
+ The namespace is an enum: `extension App { enum Item { … } }` — call sites read as domain
71
+ language (`App.Item.Service`), and registration is type-scoped, not global.
72
+
73
+ > ⚠️ **Gotcha:** Symptom — two features need the same service and one starts importing the
74
+ > other's folder. Cause — sideways dependency between sibling features. Fix — promote the
75
+ > shared logic to its own SPM target (L4) and have both features depend on it. A feature
76
+ > never reaches into a sibling feature.
77
+
78
+ ## 4. Auto-registration — one line per feature, ever
79
+
80
+ Three protocols in `Sources/App/Registry/` (`ControllersRegister`, `MigrationsRegister`,
81
+ `MiddlewaresRegister`) give every module the same wiring surface:
82
+
83
+ ```swift
84
+ // In the module: AppItem.swift
85
+ extension App.Item.Controllers: ControllersRegister {
86
+ static func allCases() -> [any RouteCollection] { [Crud()] }
87
+ }
88
+
89
+ // In Configure/routes.swift — the ONLY line the rest of the app sees:
90
+ try App.Item.Controllers.register(app: app)
91
+ ```
92
+
93
+ Adding a feature touches exactly two central lines: its controllers in `routes.swift`,
94
+ its migrations in `configure.swift`. Everything else stays inside the module folder.
95
+ This is what keeps agent diffs small: a new endpoint never rewrites the bootstrap.
96
+
97
+ ## 5. Migration ordering — explicit, central, load-bearing
98
+
99
+ `configure.swift > migrationsInit` registers every module's migrations **in an explicit
100
+ sequence**. Fluent runs them in registration order; a schema referencing another table
101
+ (foreign key) must be registered after that table's `Create`.
102
+
103
+ > ⚠️ **Gotcha:** Symptom — fresh deploy crashes with `relation "xxx" does not exist`,
104
+ > while every dev machine boots fine. Cause — dev databases migrated incrementally over
105
+ > weeks, so ordering bugs stay invisible; only a from-scratch migration run exposes them.
106
+ > Fix — order registrations by foreign-key dependency, comment WHY a module is positioned
107
+ > where it is, and verify every migration change against a scratch database (drop +
108
+ > re-migrate) before shipping.
109
+
110
+ ## 6. Where Does a New File Go?
111
+
112
+ | You are adding… | It lives in… |
113
+ |---|---|
114
+ | A new endpoint for an existing feature | `Features/<F>/Controllers/` (+ DTO if the shape changes) |
115
+ | A business rule (validation, limits, workflow) | `Features/<F>/Services/` |
116
+ | A query, a DB lookup | `Features/<F>/Repositories/` |
117
+ | A schema change | `Features/<F>/Migrations/` — NEW file, registered after its FK targets |
118
+ | A queued/scheduled job | `Features/<F>/Jobs/` (conform a JobsRegister-style surface) |
119
+ | A whole new feature | `Features/<NewF>/` copied from the Item module shape |
120
+ | A new error case | `Sources/App/Error/Failed.swift` + regenerate ERROR_CODES (CONVENTIONS.md) |
121
+ | A new environment variable | `AppConfig.Key` + `env_dist` + manifests (CONVENTIONS.md) |
122
+ | An external API client | new SPM target `Sources/<X>Client/` using AsyncHTTPClient |
123
+ | A log/metric/middleware concern | `Sources/Monitoring/` |
124
+ | A pure helper (string, date, parsing) | `Sources/{{PROJECT_NAME}}Foundation/` |
125
+
126
+ ## 7. Why This Works Exceptionally Well With AI Agents
127
+
128
+ - **Fast ground truth without infrastructure.** `swift test` boots the real app on
129
+ in-memory SQLite — full middleware stack, real migrations, zero services to start.
130
+ - **Compiler-enforced walls where it counts.** An agent that makes Monitoring import App
131
+ code gets a build error, not a slow architectural rot.
132
+ - **Grep-visible violations everywhere else.** Every folder rule above has a one-line
133
+ grep; agents can self-audit before claiming done.
134
+ - **Predictable file placement.** The feature-module shape means an agent knows the 5–7
135
+ files a feature needs; diffs stay small and reviewable.
136
+ - **The error contract is testable.** Typed errors make failure paths assertable
137
+ (`#expect(error.name == "dataNotValid")`) — failure behavior survives refactors.
138
+
139
+ Build matrix an agent should run before claiming "done":
140
+ ```bash
141
+ swift build && swift test # full stack on the host
142
+ docker run --rm -v "$PWD:/src" -w /src swift:6.2-noble swift test # Linux = the real target
143
+ bash scripts/validate-env-vars.sh # config drift check
144
+ ```
@@ -0,0 +1,121 @@
1
+ # CONVENTIONS — Server-Side Swift (Vapor 4, Swift 6 strict concurrency)
2
+
3
+ Prescriptive — follow as written. The layer rules live in `ARCHITECTURE.md`; this file is
4
+ about HOW each kind of code is written.
5
+
6
+ ## 1. Feature module anatomy
7
+
8
+ - Namespace enum per feature: `extension App { enum Item { enum Controllers {}; enum Migrations {}; enum DTO {} } }`.
9
+ Sub-enums exist ONLY for roles the feature actually has — don't pre-create empty ones.
10
+ - **One type per file**; file name = feature + role + type: `ItemControllersCrud.swift`,
11
+ `ItemEntity.swift`, `ItemMigrationCreate.swift`, `ItemService.swift`, `ItemRepository.swift`.
12
+ - The front-door file (`AppItem.swift`) holds the namespace + ALL registration conformances —
13
+ reading one file tells you everything the module plugs into.
14
+ - Checklist for a new feature `Foo`:
15
+ 1. Copy the `Features/Item/` shape → `Features/Foo/`.
16
+ 2. Conform `App.Foo.Controllers: ControllersRegister`, `App.Foo.Migrations: MigrationsRegister`.
17
+ 3. Add `try App.Foo.Controllers.register(app: app)` to `routes.swift`.
18
+ 4. Add `App.Foo.Migrations.register(app: app)` to `configure.swift` — positioned AFTER
19
+ every table its schema references.
20
+ 5. Write the feature test (boot + endpoint + typed-error case) in `Tests/AppTests/`.
21
+
22
+ ## 2. Typed errors — the public failure contract
23
+
24
+ Every intentional failure is a case of an enum in `Sources/App/Error/Failed.swift`:
25
+
26
+ ```swift
27
+ enum BadRequest: CustomError {
28
+ case dataNotValid
29
+ func convert() -> HTTPStatus { .badRequest } // ONE line — the generator parses this
30
+ }
31
+ // usage, in a Service:
32
+ guard !cleanName.isEmpty else { throw App.Failed.BadRequest.dataNotValid }
33
+ ```
34
+
35
+ The middleware (`FailedMiddleware.swift`) serializes any thrown case as
36
+ `{"code": 400, "name": "dataNotValid", "description": "Bad Request"}` and increments the
37
+ error counter. Rules:
38
+
39
+ - **Feature code never throws raw `Abort`** and never builds error Responses by hand.
40
+ `Abort` is tolerated only in infrastructure handlers (e.g. the /metrics gate).
41
+ - The **case name is the public identifier** — clients match on it. Renaming a case is a
42
+ breaking API change; treat it like one.
43
+ - Throw from **Services** (business decisions) and Controllers (HTTP-shape problems like
44
+ undecodable JSON). Repositories return facts (`nil`, `false`), never typed errors.
45
+ - `docs/ERROR_CODES.md` is **GENERATED** from the enum by `scripts/generate-error-codes.sh`.
46
+ Never write it by hand; regenerate in the same commit that touches `Failed.swift`.
47
+ A hand-written error table starts lying the week after it's written — the generated one
48
+ cannot lie.
49
+
50
+ > ⚠️ **Gotcha:** Symptom — a client breaks on an error response with a shape nobody
51
+ > documented. Cause — someone threw `Abort(.badRequest, reason: "…")` in feature code,
52
+ > which bypasses the typed contract and produces Vapor's `{"error": true, "reason": …}`
53
+ > shape instead. Fix — grep `Abort(` over `Features/` in review; it should only hit
54
+ > infrastructure files.
55
+
56
+ ## 3. Environment variables — typed, fail-fast, cross-checked
57
+
58
+ ONE pattern, no exceptions (`Sources/App/Configure/AppConfig.swift`):
59
+
60
+ - Every variable is a case of `AppConfig.Key` (CaseIterable). `Key.get` fail-fasts with
61
+ the variable's NAME if missing — the service dies at boot, not at the first request
62
+ that needed the value.
63
+ - `Environment.get` is called nowhere else. If you need a new value: add the Key case,
64
+ add a typed property on `AppConfig`, add the line to `env_dist`, add it to every deploy
65
+ manifest, run `bash scripts/validate-env-vars.sh`.
66
+ - Tests get `AppConfig.testing` through an EXPLICIT branch on `app.environment == .testing`
67
+ — never sniff the test runner from process arguments, env leftovers, or CI flags. The
68
+ environment is a parameter, not a guess.
69
+ - Config reaches code via `Application.storage` (`app.config` / `req.config`) — no global
70
+ singleton. Singletons hide the dependency and make per-test configuration impossible.
71
+ - `LOG_LEVEL` is the single sanctioned exception: read in `entrypoint.swift` before
72
+ config exists (logging must work even when configuration is broken).
73
+
74
+ > ⚠️ **Gotcha:** Symptom — deploy green, then the service dies at boot with a missing
75
+ > variable that "was added weeks ago". Cause — the variable was added to code and the dev
76
+ > .env, but never to the production manifest; nothing cross-checked them. Fix — the
77
+ > validate script runs in CI and blocks the merge, not the deploy.
78
+
79
+ ## 4. DTOs — the wire contract
80
+
81
+ - `DTO.Input` / `DTO.Output` structs conforming to `Content`, one pair per resource shape.
82
+ - **Entities never serialize directly.** The entity is a persistence detail; the DTO is
83
+ the public API. Renaming a DB column must never be an API-breaking change.
84
+ - `Output.init(entity:)` is the single mapping point — `try entity.requireID()` there,
85
+ so an unsaved entity fails loudly instead of emitting a null id.
86
+ - Decode with intent: `guard let input = try? req.content.decode(...) else { throw App.Failed.BadRequest.jsonNotDecodable }`
87
+ — undecodable JSON is a typed 400, not a Vapor default.
88
+
89
+ ## 5. Concurrency (Swift 6, strict)
90
+
91
+ - Route handlers are `@Sendable` funcs on a struct `RouteCollection`.
92
+ - Services and Repositories are `Sendable` structs holding `let db: any Database` —
93
+ created per request, no shared mutable state.
94
+ - Fluent models are `final class … Model, @unchecked Sendable` — the ONLY tolerated
95
+ `@unchecked` in the codebase, imposed by Fluent. Containment rule in
96
+ `GOTCHAS_LINUX_SWIFT.md`: entities never leave the request scope.
97
+ - **No `Task.detached`, no fire-and-forget `Task {}`** in request handlers. Work that
98
+ outlives the request goes through a queued Job (see GOTCHAS — request-scoped resources
99
+ die with the request).
100
+ - Process-wide one-time setup (metrics registry, log bootstrap) anchors to a lazy
101
+ `static let` — thread-safe by language guarantee, no locks to get wrong.
102
+
103
+ ## 6. Logging
104
+
105
+ - Always through `req.logger` / `app.logger` (request-scoped loggers carry request IDs).
106
+ Never `print()` — invisible to the structured pipeline.
107
+ - **Values go in metadata, not in the message string**: the message is a stable label,
108
+ metadata keys are queryable (`"duration_ms"`, `"path"`, `"error_type"`).
109
+ - Every `catch` that swallows or converts an error logs the operation name + the error.
110
+ - Never log secrets, tokens, or full request bodies. Probe paths (/health, /metrics) are
111
+ excluded from request logs by `HTTPLoggingMiddleware` — keep it that way.
112
+
113
+ ## 7. Naming quick reference
114
+
115
+ - Actions are verbs (`create`, `list`); queries are nouns; booleans read as assertions
116
+ (`isBlank`, `monitoringEnabled`).
117
+ - Routes: plural resources, kebab-case path segments (`/api/items`, `/api/team-invites`).
118
+ - DB: snake_case columns, plural snake_case tables (`created_at`, `items`).
119
+ - Migrations: `Create`, `AddXxx`, `FixXxx` — named for what they do, append-only.
120
+ - Names express intent, not technology (`ItemRepository`, not `ItemDBManager`).
121
+ - Comments explain WHY (the decision, the trap), never what the code already says.