@moifort/stack 0.0.0-stage → 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 (263) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +26 -2
  3. package/bin/stack.ts +109 -0
  4. package/hooks/shutdown-simulators-after-push.sh +19 -0
  5. package/package.json +40 -4
  6. package/routines/daily-health.md +78 -0
  7. package/routines/deprecations.md +23 -0
  8. package/routines/sentry-autofix.md +76 -0
  9. package/routines/stack-upgrade.md +45 -0
  10. package/rules/00-collaboration.md +81 -0
  11. package/rules/01-workflow.md +81 -0
  12. package/rules/02-commits.md +71 -0
  13. package/rules/03-code-review.md +25 -0
  14. package/rules/04-push.md +75 -0
  15. package/rules/05-release.md +40 -0
  16. package/rules/06-changelog.md +48 -0
  17. package/rules/07-appstore.md +77 -0
  18. package/rules/08-copy.md +52 -0
  19. package/rules/09-backend.md +132 -0
  20. package/rules/10-ios.md +206 -0
  21. package/rules/11-ops.md +114 -0
  22. package/rules/12-ci.md +32 -0
  23. package/rules/13-ai.md +71 -0
  24. package/rules/14-infra.md +90 -0
  25. package/rules/15-tooling.md +81 -0
  26. package/rules/16-routines.md +69 -0
  27. package/rules/17-landing.md +76 -0
  28. package/skills/appstore/SKILL.md +53 -0
  29. package/skills/domain/SKILL.md +136 -0
  30. package/skills/graphql/SKILL.md +83 -0
  31. package/skills/ios-feature/SKILL.md +94 -0
  32. package/skills/stack-install/SKILL.md +35 -0
  33. package/skills/stack-upgrade/SKILL.md +27 -0
  34. package/src/catalogue.ts +33 -0
  35. package/src/config.ts +20 -0
  36. package/src/init.ts +63 -0
  37. package/src/migrations.ts +72 -0
  38. package/src/migrations.unit.test.ts +44 -0
  39. package/src/package.ts +8 -0
  40. package/src/render.ts +13 -0
  41. package/src/render.unit.test.ts +17 -0
  42. package/src/sync.int.test.ts +103 -0
  43. package/src/sync.ts +161 -0
  44. package/src/templates.ts +121 -0
  45. package/src/validate-content.ts +65 -0
  46. package/templates/backend/.env.example +10 -0
  47. package/templates/backend/.github/workflows/deploy.yml +151 -0
  48. package/templates/backend/.github/workflows/schema.yml +59 -0
  49. package/templates/backend/.github/workflows/test-feat.yml +40 -0
  50. package/templates/backend/.github/workflows/test-int.yml +40 -0
  51. package/templates/backend/.github/workflows/test-unit.yml +64 -0
  52. package/templates/backend/docs/api-evolution.md +67 -0
  53. package/templates/backend/firebase.json +21 -0
  54. package/templates/backend/firestore.indexes.json +4 -0
  55. package/templates/backend/firestore.rules +11 -0
  56. package/templates/backend/infra/auth.tf +54 -0
  57. package/templates/backend/infra/backend.tf +6 -0
  58. package/templates/backend/infra/cicd.tf +63 -0
  59. package/templates/backend/infra/firebase.tf +26 -0
  60. package/templates/backend/infra/firestore.tf +53 -0
  61. package/templates/backend/infra/function.tf +171 -0
  62. package/templates/backend/infra/main.tf +26 -0
  63. package/templates/backend/infra/observer.tf +18 -0
  64. package/templates/backend/infra/outputs.tf +30 -0
  65. package/templates/backend/infra/project.tf +64 -0
  66. package/templates/backend/infra/scheduler.tf +28 -0
  67. package/templates/backend/infra/secrets.tf +44 -0
  68. package/templates/backend/infra/state.tf +13 -0
  69. package/templates/backend/infra/terraform.tfvars.example +16 -0
  70. package/templates/backend/infra/variables.tf +104 -0
  71. package/templates/backend/infra/versions.tf +30 -0
  72. package/templates/backend/nitro.config.ts +40 -0
  73. package/templates/backend/package.json +51 -0
  74. package/templates/backend/scripts/deprecations/inspector-rule.ts +29 -0
  75. package/templates/backend/scripts/deprecations/status.ts +50 -0
  76. package/templates/backend/scripts/deprecations/status.unit.test.ts +57 -0
  77. package/templates/backend/scripts/deprecations/usage.ts +79 -0
  78. package/templates/backend/scripts/deprecations/usage.unit.test.ts +64 -0
  79. package/templates/backend/scripts/deprecations.ts +224 -0
  80. package/templates/backend/scripts/export-graphql-schema.ts +11 -0
  81. package/templates/backend/scripts/generate-changelog-asset.ts +27 -0
  82. package/templates/backend/scripts/install-terraform.sh +61 -0
  83. package/templates/backend/scripts/tf +11 -0
  84. package/templates/backend/server/domain/changelog/business-rules.ts +60 -0
  85. package/templates/backend/server/domain/changelog/business-rules.unit.test.ts +88 -0
  86. package/templates/backend/server/domain/changelog/infrastructure/graphql/queries.ts +17 -0
  87. package/templates/backend/server/domain/changelog/infrastructure/graphql/types.ts +23 -0
  88. package/templates/backend/server/domain/changelog/primitives.ts +19 -0
  89. package/templates/backend/server/domain/changelog/query.ts +13 -0
  90. package/templates/backend/server/domain/changelog/query.unit.test.ts +19 -0
  91. package/templates/backend/server/domain/changelog/types.ts +9 -0
  92. package/templates/backend/server/domain/shared/graphql/builder.ts +52 -0
  93. package/templates/backend/server/domain/shared/graphql/errors.ts +19 -0
  94. package/templates/backend/server/domain/shared/graphql/scalars.ts +51 -0
  95. package/templates/backend/server/domain/shared/graphql/schema.ts +21 -0
  96. package/templates/backend/server/domain/shared/language.ts +26 -0
  97. package/templates/backend/server/domain/shared/page.ts +25 -0
  98. package/templates/backend/server/domain/shared/page.unit.test.ts +51 -0
  99. package/templates/backend/server/domain/shared/primitives.ts +50 -0
  100. package/templates/backend/server/domain/shared/primitives.unit.test.ts +25 -0
  101. package/templates/backend/server/domain/shared/types.ts +14 -0
  102. package/templates/backend/server/domain/user/command.ts +16 -0
  103. package/templates/backend/server/domain/user/infrastructure/graphql/inputs.ts +12 -0
  104. package/templates/backend/server/domain/user/infrastructure/graphql/mutations.ts +40 -0
  105. package/templates/backend/server/domain/user/infrastructure/graphql/queries.ts +13 -0
  106. package/templates/backend/server/domain/user/infrastructure/graphql/types.ts +35 -0
  107. package/templates/backend/server/domain/user/infrastructure/repository.ts +59 -0
  108. package/templates/backend/server/domain/user/mutations.feat.test.ts +42 -0
  109. package/templates/backend/server/domain/user/query.int.test.ts +49 -0
  110. package/templates/backend/server/domain/user/query.ts +34 -0
  111. package/templates/backend/server/domain/user/types.ts +20 -0
  112. package/templates/backend/server/domain/user/use-case.ts +18 -0
  113. package/templates/backend/server/middleware/auth.ts +54 -0
  114. package/templates/backend/server/plugins/00-sentry.ts +74 -0
  115. package/templates/backend/server/plugins/01-request-log.ts +28 -0
  116. package/templates/backend/server/plugins/02-graphql.ts +69 -0
  117. package/templates/backend/server/plugins/03-boot-time.ts +21 -0
  118. package/templates/backend/server/routes/admin/migrate.post.ts +9 -0
  119. package/templates/backend/server/routes/app-config.get.ts +15 -0
  120. package/templates/backend/server/routes/graphql.ts +52 -0
  121. package/templates/backend/server/system/app-support.ts +17 -0
  122. package/templates/backend/server/system/config/index.ts +18 -0
  123. package/templates/backend/server/system/config/primitives.ts +20 -0
  124. package/templates/backend/server/system/config/primitives.unit.test.ts +13 -0
  125. package/templates/backend/server/system/config/types.ts +8 -0
  126. package/templates/backend/server/system/firebase.ts +19 -0
  127. package/templates/backend/server/system/identity.ts +18 -0
  128. package/templates/backend/server/system/logger.ts +119 -0
  129. package/templates/backend/server/system/logger.unit.test.ts +109 -0
  130. package/templates/backend/server/system/migration/migrations/index.ts +6 -0
  131. package/templates/backend/server/system/migration/primitives.ts +18 -0
  132. package/templates/backend/server/system/migration/runner.ts +52 -0
  133. package/templates/backend/server/system/migration/types.ts +16 -0
  134. package/templates/backend/server/system/read-meter.ts +100 -0
  135. package/templates/backend/server/system/read-meter.unit.test.ts +97 -0
  136. package/templates/backend/server/system/request-cache.ts +139 -0
  137. package/templates/backend/server/system/request-cache.unit.test.ts +166 -0
  138. package/templates/backend/server/system/secret-box.ts +49 -0
  139. package/templates/backend/server/system/secret-box.unit.test.ts +46 -0
  140. package/templates/backend/server/test/bdd.ts +12 -0
  141. package/templates/backend/server/test/fake-firestore.ts +483 -0
  142. package/templates/backend/server/utils/apollo.ts +13 -0
  143. package/templates/backend/server/utils/fetch.ts +5 -0
  144. package/templates/backend/server/utils/firestore.ts +91 -0
  145. package/templates/backend/server/utils/input.ts +36 -0
  146. package/templates/backend/server/utils/input.unit.test.ts +43 -0
  147. package/templates/backend/server/utils/slug.ts +21 -0
  148. package/templates/backend/tsconfig.json +16 -0
  149. package/templates/base/.github/workflows/renovate.yml +17 -0
  150. package/templates/base/CHANGELOG.fr.md +3 -0
  151. package/templates/base/CHANGELOG.md +7 -0
  152. package/templates/base/CLAUDE.md +34 -0
  153. package/templates/base/README.md +12 -0
  154. package/templates/base/biome.json +47 -0
  155. package/templates/base/bunfig.toml +2 -0
  156. package/templates/base/docs/adr/0001-built-on-the-polyforms-stack.md +21 -0
  157. package/templates/base/docs/data-recovery.md +116 -0
  158. package/templates/base/docs/ideas.md +4 -0
  159. package/templates/base/project-context.md +43 -0
  160. package/templates/base/renovate.json +37 -0
  161. package/templates/ios/.github/workflows/build-ios.yml +168 -0
  162. package/templates/ios/.github/workflows/ios-upload.yml +308 -0
  163. package/templates/ios/.github/workflows/release-ios.yml +178 -0
  164. package/templates/ios/.github/workflows/testflight-ios.yml +57 -0
  165. package/templates/ios/infra/Gemfile +5 -0
  166. package/templates/ios/infra/fastlane/Appfile +1 -0
  167. package/templates/ios/infra/fastlane/Fastfile +54 -0
  168. package/templates/ios/infra/fastlane/metadata/copyright.txt +1 -0
  169. package/templates/ios/infra/fastlane/metadata/en-US/description.txt +0 -0
  170. package/templates/ios/infra/fastlane/metadata/en-US/keywords.txt +0 -0
  171. package/templates/ios/infra/fastlane/metadata/en-US/name.txt +1 -0
  172. package/templates/ios/infra/fastlane/metadata/en-US/privacy_url.txt +1 -0
  173. package/templates/ios/infra/fastlane/metadata/en-US/promotional_text.txt +0 -0
  174. package/templates/ios/infra/fastlane/metadata/en-US/subtitle.txt +1 -0
  175. package/templates/ios/infra/fastlane/metadata/en-US/support_url.txt +1 -0
  176. package/templates/ios/infra/fastlane/metadata/fr-FR/description.txt +0 -0
  177. package/templates/ios/infra/fastlane/metadata/fr-FR/keywords.txt +0 -0
  178. package/templates/ios/infra/fastlane/metadata/fr-FR/name.txt +1 -0
  179. package/templates/ios/infra/fastlane/metadata/fr-FR/privacy_url.txt +1 -0
  180. package/templates/ios/infra/fastlane/metadata/fr-FR/promotional_text.txt +0 -0
  181. package/templates/ios/infra/fastlane/metadata/fr-FR/subtitle.txt +1 -0
  182. package/templates/ios/infra/fastlane/metadata/fr-FR/support_url.txt +1 -0
  183. package/templates/ios/infra/fastlane/metadata/primary_category.txt +1 -0
  184. package/templates/ios/infra/ios.tf +28 -0
  185. package/templates/ios/ios/ExportOptions.plist +26 -0
  186. package/templates/ios/ios/apollo-codegen-config.json +16 -0
  187. package/templates/ios/ios/project.yml +94 -0
  188. package/templates/ios/ios/{{APP_NAME}}/App.swift +46 -0
  189. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  190. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  191. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/Contents.json +6 -0
  192. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
  193. package/templates/ios/ios/{{APP_NAME}}/ContentView.swift +17 -0
  194. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AppleNonce.swift +33 -0
  195. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthRoot.swift +74 -0
  196. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthSession.swift +86 -0
  197. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/LoginScreen.swift +83 -0
  198. package/templates/ios/ios/{{APP_NAME}}/Features/Home/HomeScreen.swift +21 -0
  199. package/templates/ios/ios/{{APP_NAME}}/Features/Home/components/pages/HomePage.swift +40 -0
  200. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/AccountEnvironment.swift +7 -0
  201. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/GraphQL/Onboarding.graphql +16 -0
  202. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingAPI.swift +28 -0
  203. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingClient.swift +26 -0
  204. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingGate.swift +50 -0
  205. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingScreen.swift +69 -0
  206. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/FirstNamePage.swift +66 -0
  207. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/WelcomePage.swift +37 -0
  208. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogScreen.swift +25 -0
  209. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogViewModel.swift +27 -0
  210. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/GraphQL/Settings.graphql +11 -0
  211. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsScreen.swift +30 -0
  212. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsViewModel.swift +51 -0
  213. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsAPI.swift +37 -0
  214. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsClient.swift +40 -0
  215. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsHomeScreen.swift +39 -0
  216. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/DeleteAccountButton.swift +30 -0
  217. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SettingsRow.swift +56 -0
  218. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SignOutButton.swift +23 -0
  219. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ChangelogPage.swift +59 -0
  220. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/FeedbackSheet.swift +118 -0
  221. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ProfileSettingsPage.swift +90 -0
  222. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/SettingsHomePage.swift +70 -0
  223. package/templates/ios/ios/{{APP_NAME}}/Info.plist +14 -0
  224. package/templates/ios/ios/{{APP_NAME}}/Localizable.xcstrings +1030 -0
  225. package/templates/ios/ios/{{APP_NAME}}/PrivacyInfo.xcprivacy +122 -0
  226. package/templates/ios/ios/{{APP_NAME}}/Shared/APIClient.swift +23 -0
  227. package/templates/ios/ios/{{APP_NAME}}/Shared/AcceptLanguageInterceptor.swift +20 -0
  228. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncButton.swift +73 -0
  229. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncToolbarButton.swift +69 -0
  230. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/ToolbarIconButton.swift +46 -0
  231. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/EmptyState.swift +132 -0
  232. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/LabeledInfoRow.swift +29 -0
  233. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/CaptionLabelStyle.swift +45 -0
  234. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/Copyable.swift +32 -0
  235. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/EdgeToEdgeSeparator.swift +14 -0
  236. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/RowLabelStyle.swift +58 -0
  237. package/templates/ios/ios/{{APP_NAME}}/Shared/CursorPage.swift +26 -0
  238. package/templates/ios/ios/{{APP_NAME}}/Shared/DataChanges.swift +19 -0
  239. package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorPresenter.swift +62 -0
  240. package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorReporting.swift +35 -0
  241. package/templates/ios/ios/{{APP_NAME}}/Shared/FeedbackReporting.swift +42 -0
  242. package/templates/ios/ios/{{APP_NAME}}/Shared/FirebaseTokenInterceptor.swift +49 -0
  243. package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLClient.swift +54 -0
  244. package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLHelpers.swift +128 -0
  245. package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLLoggingInterceptor.swift +28 -0
  246. package/templates/ios/ios/{{APP_NAME}}/Shared/LoadGate.swift +30 -0
  247. package/templates/ios/ios/{{APP_NAME}}/Shared/Secrets.swift.example +9 -0
  248. package/templates/ios/ios/{{APP_NAME}}/Shared/Services/AppSupportGate.swift +50 -0
  249. package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/StartupLoadingView.swift +23 -0
  250. package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/UpdateRequiredView.swift +24 -0
  251. package/templates/ios/ios/{{APP_NAME}}/Shared/Showcase.swift +13 -0
  252. package/templates/ios/ios/{{APP_NAME}}/Shared/SnapshotCache.swift +78 -0
  253. package/templates/ios/ios/{{APP_NAME}}/Shared/UITestEnvironment.swift +70 -0
  254. package/templates/ios/ios/{{APP_NAME}}/{{APP_NAME}}.entitlements +10 -0
  255. package/templates/ios/ios/{{APP_NAME}}Tests/Onboarding/OnboardingGateTests.swift +38 -0
  256. package/templates/ios/ios/{{APP_NAME}}Tests/Settings/SettingsViewModelTests.swift +63 -0
  257. package/templates/ios/ios/{{APP_NAME}}Tests/Support/TestDoubles.swift +35 -0
  258. package/templates/ios/package.merge.json +10 -0
  259. package/templates/ios/scripts/apollo-codegen.ts +31 -0
  260. package/templates/ios/scripts/check-ios-components.ts +72 -0
  261. package/templates/ios/scripts/check-strings.ts +103 -0
  262. package/templates/ios/scripts/install-device.sh +29 -0
  263. package/templates/ios/scripts/testflight.ts +213 -0
@@ -0,0 +1,53 @@
1
+ resource "google_firebaserules_ruleset" "firestore" {
2
+ provider = google-beta
3
+ project = google_project.this.project_id
4
+
5
+ source {
6
+ files {
7
+ name = "firestore.rules"
8
+ content = file("${path.root}/../firestore.rules")
9
+ }
10
+ }
11
+
12
+ depends_on = [google_firestore_database.default]
13
+ }
14
+
15
+ resource "google_firebaserules_release" "firestore" {
16
+ provider = google-beta
17
+ project = google_project.this.project_id
18
+ name = "cloud.firestore"
19
+ ruleset_name = "projects/${google_project.this.project_id}/rulesets/${google_firebaserules_ruleset.firestore.name}"
20
+ }
21
+
22
+ locals {
23
+ firestore_indexes = jsondecode(file("${path.root}/../firestore.indexes.json")).indexes
24
+ }
25
+
26
+ resource "google_firestore_index" "composite" {
27
+ # Keyed by a stable identifier (collection + fields), not the array position, so
28
+ # adding or removing an index never re-keys — and therefore never destroys and
29
+ # recreates — the others. A position key caused a transient FAILED_PRECONDITION
30
+ # whenever the list changed in the middle.
31
+ for_each = {
32
+ for idx in local.firestore_indexes :
33
+ "${idx.collectionGroup}|${join(",", [for f in idx.fields : "${f.fieldPath}:${try(f.order, try(f.arrayConfig, "NA"))}"])}" => idx
34
+ }
35
+
36
+ provider = google-beta
37
+ project = google_project.this.project_id
38
+ database = google_firestore_database.default.name
39
+ collection = each.value.collectionGroup
40
+ query_scope = each.value.queryScope
41
+
42
+ # A field is indexed either by order (a scalar, ascending or descending) or by
43
+ # array config (`CONTAINS`, what array-contains queries need). Firestore rejects
44
+ # a field that declares neither, so both are read from firestore.indexes.json.
45
+ dynamic "fields" {
46
+ for_each = each.value.fields
47
+ content {
48
+ field_path = fields.value.fieldPath
49
+ order = try(fields.value.order, null)
50
+ array_config = try(fields.value.arrayConfig, null)
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,171 @@
1
+ resource "google_service_account" "function" {
2
+ project = google_project.this.project_id
3
+ account_id = "{{APP_SLUG}}-runtime"
4
+ display_name = "{{APP_NAME}} runtime"
5
+ }
6
+
7
+ resource "google_project_iam_member" "function_firestore" {
8
+ project = google_project.this.project_id
9
+ role = "roles/datastore.user"
10
+ member = "serviceAccount:${google_service_account.function.email}"
11
+ }
12
+
13
+ # Deleting an account ends with deleting its Firebase Auth user, which needs
14
+ # Firebase Authentication admin rights: without them the wipe stops at its last
15
+ # step with "insufficient permission", every trace of data already gone.
16
+ resource "google_project_iam_member" "function_firebase_auth" {
17
+ project = google_project.this.project_id
18
+ role = "roles/firebaseauth.admin"
19
+ member = "serviceAccount:${google_service_account.function.email}"
20
+ }
21
+
22
+ # Cloud Functions Gen 2 builds run in Cloud Build under the default Compute
23
+ # service account. In a fresh project that role binding is not granted by
24
+ # default, so the first deploy fails. Bound explicitly to make the bootstrap
25
+ # deterministic.
26
+ data "google_compute_default_service_account" "default" {
27
+ project = google_project.this.project_id
28
+ depends_on = [google_project_service.apis]
29
+ }
30
+
31
+ resource "google_project_iam_member" "compute_default_builder" {
32
+ project = google_project.this.project_id
33
+ role = "roles/cloudbuild.builds.builder"
34
+ member = "serviceAccount:${data.google_compute_default_service_account.default.email}"
35
+ }
36
+
37
+ resource "google_secret_manager_secret_iam_member" "function" {
38
+ for_each = google_secret_manager_secret.this
39
+ project = google_project.this.project_id
40
+ secret_id = each.value.secret_id
41
+ role = "roles/secretmanager.secretAccessor"
42
+ member = "serviceAccount:${google_service_account.function.email}"
43
+ }
44
+
45
+ resource "google_storage_bucket" "source" {
46
+ project = google_project.this.project_id
47
+ name = "${google_project.this.project_id}-fn-source"
48
+ location = local.bucket_location
49
+ force_destroy = true
50
+ uniform_bucket_level_access = true
51
+
52
+ depends_on = [google_project_service.apis]
53
+ }
54
+
55
+ data "archive_file" "source" {
56
+ type = "zip"
57
+ source_dir = "${path.root}/../.output/server"
58
+ output_path = "${path.root}/../.output/server-source.zip"
59
+ }
60
+
61
+ resource "google_storage_bucket_object" "source" {
62
+ name = "source-${data.archive_file.source.output_md5}.zip"
63
+ bucket = google_storage_bucket.source.name
64
+ source = data.archive_file.source.output_path
65
+ }
66
+
67
+ resource "google_cloudfunctions2_function" "server" {
68
+ provider = google-beta
69
+ project = google_project.this.project_id
70
+ name = "{{APP_SLUG}}-server"
71
+ location = var.region
72
+
73
+ build_config {
74
+ runtime = "nodejs22"
75
+ entry_point = "server"
76
+
77
+ source {
78
+ storage_source {
79
+ bucket = google_storage_bucket.source.name
80
+ object = google_storage_bucket_object.source.name
81
+ }
82
+ }
83
+ }
84
+
85
+ service_config {
86
+ # One whole CPU and several requests per instance: the app asks a screen's
87
+ # queries at once, and they mostly wait on Firestore. The globals are
88
+ # per-instance on purpose and the request cache lives on the event, so
89
+ # nothing leaks between requests.
90
+ available_memory = "512Mi"
91
+ available_cpu = "1"
92
+ max_instance_request_concurrency = 8
93
+ # Mirrors `timeoutSeconds` in nitro.config.ts, which Terraform does not read:
94
+ # raise both together.
95
+ timeout_seconds = 60
96
+ max_instance_count = 100
97
+ # Scale to zero on purpose: cold starts are accepted, never a warm instance.
98
+ min_instance_count = 0
99
+ service_account_email = google_service_account.function.email
100
+
101
+ environment_variables = {
102
+ NITRO_FIREBASE_PROJECT_ID = google_project.this.project_id
103
+ }
104
+
105
+ dynamic "secret_environment_variables" {
106
+ for_each = google_secret_manager_secret.this
107
+ content {
108
+ key = "NITRO_${replace(upper(secret_environment_variables.key), "-", "_")}"
109
+ project_id = google_project.this.project_id
110
+ secret = secret_environment_variables.value.secret_id
111
+ version = "latest"
112
+ }
113
+ }
114
+ }
115
+
116
+ depends_on = [
117
+ google_project_iam_member.function_firestore,
118
+ google_project_iam_member.compute_default_builder,
119
+ google_secret_manager_secret_iam_member.function,
120
+ ]
121
+ }
122
+
123
+ # Auth is enforced inside the function (server/middleware/auth.ts verifies the
124
+ # Firebase ID token), so the underlying Cloud Run service is publicly invokable.
125
+ resource "google_cloud_run_service_iam_member" "invoker" {
126
+ project = google_project.this.project_id
127
+ location = var.region
128
+ service = google_cloudfunctions2_function.server.name
129
+ role = "roles/run.invoker"
130
+ member = "allUsers"
131
+
132
+ depends_on = [google_org_policy_policy.allow_public_iam]
133
+ }
134
+
135
+ # Every deploy builds a container image into this repository, which Cloud
136
+ # Functions creates on its first deploy and never prunes. Any image is rebuilt
137
+ # from its commit, so only the running one and the one before are kept.
138
+ import {
139
+ to = google_artifact_registry_repository.functions
140
+ id = "projects/${var.project_id}/locations/${var.region}/repositories/gcf-artifacts"
141
+ }
142
+
143
+ resource "google_artifact_registry_repository" "functions" {
144
+ project = google_project.this.project_id
145
+ location = var.region
146
+ repository_id = "gcf-artifacts"
147
+ format = "DOCKER"
148
+ description = "This repository is created and used by Cloud Functions for storing function docker images."
149
+
150
+ cleanup_policy_dry_run = false
151
+
152
+ cleanup_policies {
153
+ id = "delete-every-version"
154
+ action = "DELETE"
155
+ condition {
156
+ tag_state = "ANY"
157
+ }
158
+ }
159
+
160
+ cleanup_policies {
161
+ id = "keep-the-two-latest"
162
+ action = "KEEP"
163
+ most_recent_versions {
164
+ keep_count = 2
165
+ }
166
+ }
167
+
168
+ lifecycle {
169
+ ignore_changes = [labels]
170
+ }
171
+ }
@@ -0,0 +1,26 @@
1
+ provider "google" {
2
+ project = var.project_id
3
+ region = var.region
4
+ billing_project = local.quota_project
5
+ user_project_override = true
6
+ }
7
+
8
+ provider "google-beta" {
9
+ project = var.project_id
10
+ region = var.region
11
+ billing_project = local.quota_project
12
+ user_project_override = true
13
+ }
14
+
15
+ locals {
16
+ quota_project = var.quota_project_id != "" ? var.quota_project_id : var.project_id
17
+
18
+ # Cloud Storage multi-region IDs are uppercase ("EU", "US", "ASIA"),
19
+ # while Firestore uses lowercase ("eur3", "nam5"). Map between the two
20
+ # so buckets can be co-located with Firestore without a separate var.
21
+ bucket_location = lookup(
22
+ { eur3 = "EU", nam5 = "US", asia1 = "ASIA" },
23
+ var.firestore_location,
24
+ var.firestore_location,
25
+ )
26
+ }
@@ -0,0 +1,18 @@
1
+ # The organisation's read-only observer account (one for every project, living
2
+ # in polyforms-billing) is granted viewer roles here, so the scheduled routines
3
+ # read logs, metrics, scheduler state and Firestore without a personal login.
4
+ # Nothing it holds can change the project.
5
+ resource "google_project_iam_member" "observer" {
6
+ for_each = var.observer_service_account == "" ? toset([]) : toset([
7
+ "roles/logging.viewer",
8
+ "roles/monitoring.viewer",
9
+ "roles/cloudscheduler.viewer",
10
+ "roles/cloudfunctions.viewer",
11
+ "roles/run.viewer",
12
+ "roles/datastore.viewer",
13
+ ])
14
+
15
+ project = google_project.this.project_id
16
+ role = each.value
17
+ member = "serviceAccount:${var.observer_service_account}"
18
+ }
@@ -0,0 +1,30 @@
1
+ output "project_id" {
2
+ value = google_project.this.project_id
3
+ description = "GCP project id"
4
+ }
5
+
6
+ output "function_url" {
7
+ value = google_cloudfunctions2_function.server.service_config[0].uri
8
+ description = "HTTPS URL of the Cloud Function (Gen 2)"
9
+ }
10
+
11
+ output "admin_token" {
12
+ value = local.admin_token_value
13
+ sensitive = true
14
+ description = "Bearer token for /admin/* routes (used by the post-deploy migration step)"
15
+ }
16
+
17
+ output "tfstate_bucket" {
18
+ value = google_storage_bucket.tfstate.name
19
+ description = "GCS bucket holding the Terraform state. Referenced by infra/backend.tf."
20
+ }
21
+
22
+ output "deploy_sa_email" {
23
+ value = google_service_account.deploy.email
24
+ description = "Service account impersonated by the GitHub Actions deploy workflow (GitHub secret GCP_DEPLOY_SA)"
25
+ }
26
+
27
+ output "wif_provider" {
28
+ value = google_iam_workload_identity_pool_provider.github.name
29
+ description = "Full resource name of the WIF provider (GitHub secret GCP_WIF_PROVIDER)"
30
+ }
@@ -0,0 +1,64 @@
1
+ resource "google_project" "this" {
2
+ name = var.project_name
3
+ project_id = var.project_id
4
+ org_id = var.org_id
5
+ folder_id = var.folder_id
6
+ billing_account = var.billing_account_id
7
+
8
+ labels = {
9
+ app = "{{APP_SLUG}}"
10
+ }
11
+ }
12
+
13
+ resource "google_project_service" "apis" {
14
+ for_each = toset([
15
+ "firebase.googleapis.com",
16
+ "firestore.googleapis.com",
17
+ "identitytoolkit.googleapis.com",
18
+ "cloudfunctions.googleapis.com",
19
+ "cloudbuild.googleapis.com",
20
+ "compute.googleapis.com",
21
+ "run.googleapis.com",
22
+ "artifactregistry.googleapis.com",
23
+ "eventarc.googleapis.com",
24
+ "storage.googleapis.com",
25
+ "secretmanager.googleapis.com",
26
+ "iam.googleapis.com",
27
+ "iamcredentials.googleapis.com",
28
+ "serviceusage.googleapis.com",
29
+ "cloudresourcemanager.googleapis.com",
30
+ "firebaserules.googleapis.com",
31
+ "orgpolicy.googleapis.com",
32
+ "cloudscheduler.googleapis.com",
33
+ "logging.googleapis.com",
34
+ "monitoring.googleapis.com",
35
+ ])
36
+
37
+ project = google_project.this.project_id
38
+ service = each.value
39
+ disable_on_destroy = false
40
+ }
41
+
42
+ # Many GCP organizations enforce iam.allowedPolicyMemberDomains, which blocks
43
+ # IAM bindings to allUsers. The Cloud Run service must be publicly invokable
44
+ # because auth is enforced inside the function (Firebase ID token verification).
45
+ # The constraint is overridden at the project scope, never org-wide.
46
+ resource "google_org_policy_policy" "allow_public_iam" {
47
+ parent = "projects/${google_project.this.project_id}"
48
+ name = "projects/${google_project.this.project_id}/policies/iam.allowedPolicyMemberDomains"
49
+
50
+ spec {
51
+ rules {
52
+ allow_all = "TRUE"
53
+ }
54
+ }
55
+
56
+ depends_on = [google_project_service.apis]
57
+ }
58
+
59
+ # Org policy changes take about a minute to propagate before downstream IAM
60
+ # bindings succeed. Without this gate the first apply fails and needs a rerun.
61
+ resource "time_sleep" "wait_for_org_policy" {
62
+ depends_on = [google_org_policy_policy.allow_public_iam]
63
+ create_duration = "90s"
64
+ }
@@ -0,0 +1,28 @@
1
+ # One Cloud Scheduler job per scheduled route of the backend. A route called
2
+ # here is idempotent (a retry neither pays twice nor notifies twice), answers
3
+ # quickly, does its work in bounded batches, and is gated by the admin token,
4
+ # exactly like the migration step of the Deploy workflow.
5
+ #
6
+ # resource "google_cloud_scheduler_job" "example" {
7
+ # project = google_project.this.project_id
8
+ # region = var.region
9
+ # name = "example"
10
+ # schedule = "0 6 * * *"
11
+ # time_zone = "Europe/Paris"
12
+ #
13
+ # attempt_deadline = "60s"
14
+ #
15
+ # retry_config {
16
+ # retry_count = 1
17
+ # }
18
+ #
19
+ # http_target {
20
+ # http_method = "POST"
21
+ # uri = "${google_cloudfunctions2_function.server.service_config[0].uri}/admin/example"
22
+ # headers = {
23
+ # Authorization = "Bearer ${local.admin_token_value}"
24
+ # }
25
+ # }
26
+ #
27
+ # depends_on = [google_project_service.apis]
28
+ # }
@@ -0,0 +1,44 @@
1
+ resource "random_password" "admin_token" {
2
+ count = var.admin_token == null ? 1 : 0
3
+ length = 48
4
+ special = false
5
+ }
6
+
7
+ locals {
8
+ admin_token_value = var.admin_token != null ? var.admin_token : random_password.admin_token[0].result
9
+
10
+ # Every secret the function reads, by Secret Manager id. The id becomes the
11
+ # NITRO_* variable name (function.tf). Generated material uses `random_bytes`,
12
+ # never `random_id`: the latter makes the value its own id, which Terraform
13
+ # prints into the build log of a public repository.
14
+ secret_values = {
15
+ admin-token = local.admin_token_value
16
+ sentry-dsn = var.sentry_dsn
17
+ }
18
+
19
+ # Secret Manager rejects empty payloads, so optional secrets are skipped when
20
+ # blank. Iterating a map of sensitive values would propagate sensitivity into
21
+ # for_each keys, which Terraform forbids, hence nonsensitive().
22
+ secret_ids = toset(compact([
23
+ "admin-token",
24
+ nonsensitive(var.sentry_dsn) != "" ? "sentry-dsn" : "",
25
+ ]))
26
+ }
27
+
28
+ resource "google_secret_manager_secret" "this" {
29
+ for_each = local.secret_ids
30
+ project = google_project.this.project_id
31
+ secret_id = each.value
32
+
33
+ replication {
34
+ auto {}
35
+ }
36
+
37
+ depends_on = [google_project_service.apis]
38
+ }
39
+
40
+ resource "google_secret_manager_secret_version" "this" {
41
+ for_each = local.secret_ids
42
+ secret = google_secret_manager_secret.this[each.value].id
43
+ secret_data = local.secret_values[each.value]
44
+ }
@@ -0,0 +1,13 @@
1
+ resource "google_storage_bucket" "tfstate" {
2
+ project = google_project.this.project_id
3
+ name = "${google_project.this.project_id}-tfstate"
4
+ location = local.bucket_location
5
+ force_destroy = false
6
+ uniform_bucket_level_access = true
7
+
8
+ versioning {
9
+ enabled = true
10
+ }
11
+
12
+ depends_on = [google_project_service.apis]
13
+ }
@@ -0,0 +1,16 @@
1
+ project_id = "{{APP_SLUG}}-polyforms"
2
+ project_name = "{{APP_NAME}}"
3
+ org_id = "123456789012" # OR set folder_id instead
4
+ billing_account_id = "AAAA-BBBB-CCCC-DDDD"
5
+ region = "europe-west3"
6
+ firestore_location = "eur3"
7
+
8
+ # Apple Sign-In (download the .p8 and the ids from https://developer.apple.com/account)
9
+ apple_team_id = "ABCD123456"
10
+ apple_services_id = "{{BUNDLE_ID}}.signin"
11
+ apple_key_id = "KEY1234567"
12
+ apple_private_key_path = "./AuthKey_KEY1234567.p8"
13
+
14
+ # Backend secrets
15
+ sentry_dsn = "" # optional
16
+ # admin_token = "..." # optional, auto-generated if omitted
@@ -0,0 +1,104 @@
1
+ variable "project_id" {
2
+ description = "Globally unique GCP project id ({{APP_SLUG}}-polyforms)"
3
+ type = string
4
+ }
5
+
6
+ variable "project_name" {
7
+ description = "Human-readable project name shown in the GCP console"
8
+ type = string
9
+ default = "{{APP_NAME}}"
10
+ }
11
+
12
+ variable "org_id" {
13
+ description = "GCP organization id (mutually exclusive with folder_id)"
14
+ type = string
15
+ default = null
16
+ }
17
+
18
+ variable "folder_id" {
19
+ description = "GCP folder id (mutually exclusive with org_id)"
20
+ type = string
21
+ default = null
22
+ }
23
+
24
+ variable "billing_account_id" {
25
+ description = "GCP billing account id (AAAA-BBBB-CCCC-DDDD)"
26
+ type = string
27
+ }
28
+
29
+ variable "region" {
30
+ description = "Region for the Cloud Function (Gen 2)"
31
+ type = string
32
+ default = "europe-west3"
33
+ }
34
+
35
+ variable "firestore_location" {
36
+ description = "Firestore multi-region or region (eur3, europe-west3)"
37
+ type = string
38
+ default = "eur3"
39
+ }
40
+
41
+ variable "github_repo" {
42
+ description = "GitHub repository (owner/name) allowed to deploy via Workload Identity Federation"
43
+ type = string
44
+ default = "{{GITHUB_REPO}}"
45
+ }
46
+
47
+ variable "observer_service_account" {
48
+ description = "Email of the organisation's read-only observer account (lives in polyforms-billing). Blank grants nothing."
49
+ type = string
50
+ default = ""
51
+ }
52
+
53
+ variable "quota_project_id" {
54
+ description = <<-EOT
55
+ Project the provider attributes API quota to (user_project_override).
56
+
57
+ Defaults to the project being created, which is right in steady state and
58
+ impossible on the very first apply: the billing pre-flight calls
59
+ cloudbilling.googleapis.com against a project that does not exist yet.
60
+ Point this at an existing project for the bootstrap run, then drop it.
61
+ EOT
62
+ type = string
63
+ default = ""
64
+ }
65
+
66
+ # Apple Sign-In. All blank leaves the provider unconfigured (auth.tf).
67
+ variable "apple_team_id" {
68
+ description = "Apple Developer Team ID (10 characters)"
69
+ type = string
70
+ default = ""
71
+ }
72
+
73
+ variable "apple_services_id" {
74
+ description = "Apple Sign-In Services ID (the OAuth client_id)"
75
+ type = string
76
+ default = ""
77
+ }
78
+
79
+ variable "apple_key_id" {
80
+ description = "Apple private key ID (10 characters, matches the .p8 filename)"
81
+ type = string
82
+ default = ""
83
+ }
84
+
85
+ variable "apple_private_key_path" {
86
+ description = "Path to the AuthKey_XXXXXXXXXX.p8 file from Apple Developer"
87
+ type = string
88
+ default = ""
89
+ }
90
+
91
+ # Backend secrets
92
+ variable "sentry_dsn" {
93
+ description = "Sentry DSN of the {{APP_SLUG}}-node project (empty disables Sentry). Only ever takes effect in the deployed function."
94
+ type = string
95
+ sensitive = true
96
+ default = ""
97
+ }
98
+
99
+ variable "admin_token" {
100
+ description = "Bearer token guarding /admin/* routes. Auto-generated if null."
101
+ type = string
102
+ sensitive = true
103
+ default = null
104
+ }
@@ -0,0 +1,30 @@
1
+ terraform {
2
+ required_version = ">= 1.6"
3
+
4
+ required_providers {
5
+ google = {
6
+ source = "hashicorp/google"
7
+ version = "~> 8.0"
8
+ }
9
+ google-beta = {
10
+ source = "hashicorp/google-beta"
11
+ version = "~> 8.0"
12
+ }
13
+ archive = {
14
+ source = "hashicorp/archive"
15
+ version = "~> 2.4"
16
+ }
17
+ local = {
18
+ source = "hashicorp/local"
19
+ version = "~> 2.5"
20
+ }
21
+ random = {
22
+ source = "hashicorp/random"
23
+ version = "~> 3.6"
24
+ }
25
+ time = {
26
+ source = "hashicorp/time"
27
+ version = "~> 0.14"
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,40 @@
1
+ export default defineNitroConfig({
2
+ compatibilityDate: '2026-09-14',
3
+ experimental: { asyncContext: true },
4
+ srcDir: 'server',
5
+ ignore: ['**/*.test.ts'],
6
+ preset: 'firebase',
7
+ firebase: {
8
+ gen: 2,
9
+ nodeVersion: '22',
10
+ httpsOptions: {
11
+ region: 'europe-west3',
12
+ memory: '512MiB',
13
+ // Only `firebase deploy` reads this block. The deploy goes through
14
+ // Terraform, so the ceiling that applies is `timeout_seconds` in
15
+ // infra/function.tf; the two are kept equal so that neither misleads.
16
+ timeoutSeconds: 60,
17
+ concurrency: 80,
18
+ },
19
+ },
20
+ // Rollup minifies the whole backend into one file. The maps are uploaded to
21
+ // Sentry at deploy time and stay out of the deployed bundle.
22
+ sourceMap: true,
23
+ rollupConfig: {
24
+ treeshake: {
25
+ moduleSideEffects: (id) => id.includes('/graphql/') || id.includes('node_modules'),
26
+ },
27
+ },
28
+ runtimeConfig: {
29
+ adminToken: '',
30
+ sentryDsn: '',
31
+ // Baked in at build time by the deploy workflow (the git SHA), so Sentry can
32
+ // tell which deploy an error comes from and pick the matching source maps.
33
+ sentryRelease: process.env.SENTRY_RELEASE ?? '',
34
+ devUserId: '',
35
+ // The project the function runs in, set by Terraform.
36
+ firebaseProjectId: '',
37
+ // Dev only: the origin the simulator reaches this server at.
38
+ publicBaseUrl: 'http://127.0.0.1:3000',
39
+ },
40
+ })